Skip to content

Conversation

@KrishnaAgarwal7531
Copy link
Contributor

No description provided.

@coderabbitai
Copy link

coderabbitai bot commented Jan 30, 2026

📝 Walkthrough

Walkthrough

Adds two frontend apps (tenders-finder and tutors-finder) with a full Vite+React+TypeScript UI library, many Radix/ShadCN components, theme and Tailwind configs, utilities, and hooks. Adds Supabase client/types and multiple serverless functions: AI-powered link discovery and SSE proxying to Mino.ai. Introduces streaming multi-agent search hooks (useTenderSearch / useTutorSearch) that orchestrate per-link SSE requests, parse events, and incrementally update agent and result state. Also adds an in-memory toast system, tests, and build/tooling configurations.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Frontend as Browser App
  participant SupabaseFn as Supabase Function (mino-tender-search)
  participant Mino as Mino API
  User->>Frontend: Start search (sector + link)
  Frontend->>SupabaseFn: POST { sector, url, agentId }
  SupabaseFn->>Mino: POST /v1/automation/run-sse (url, goal) [X-API-Key]
  Mino-->>SupabaseFn: stream SSE (data: {...})
  SupabaseFn-->>Frontend: stream SSE events (text/event-stream)
  Frontend->>Frontend: parse events -> update agent status, streamingUrl, tenders
  Frontend-->User: render live previews and incremental results
Loading
sequenceDiagram
  participant Frontend as Browser App
  participant DiscoverFn as Supabase Function (discover-tender-links)
  participant LovableAI as Lovable AI Gateway
  Frontend->>DiscoverFn: POST { sector } (discover request)
  DiscoverFn->>LovableAI: AI prompt -> request list of links
  LovableAI-->>DiscoverFn: text response (may contain JSON list)
  DiscoverFn->>DiscoverFn: extract/validate JSON or use fallback list
  DiscoverFn-->>Frontend: JSON { links: [...] }
  Frontend->>Frontend: merge/dedupe links -> start per-link search
Loading

Possibly related PRs

Suggested reviewers

  • lozzle
🚥 Pre-merge checks | ✅ 1 | ❌ 1
❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Description check ❓ Inconclusive No pull request description provided, leaving reviewers without contextual details beyond the title and file changes. Add a brief description summarizing purpose, key files added, setup/deploy steps, required env vars (e.g. VITE_SUPABASE_*), and any testing or migration notes.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed Title accurately and concisely states the primary change (adding the tenders finder) and aligns with the files added in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🤖 Fix all issues with AI agents
In `@tenders-finder/.env`:
- Around line 1-3: The committed .env contains leaked secrets
(VITE_SUPABASE_PROJECT_ID, VITE_SUPABASE_PUBLISHABLE_KEY, VITE_SUPABASE_URL);
remove these real values and replace them with placeholder values in the file,
add .env to .gitignore, and rotate/revoke the exposed publishable key in
Supabase immediately; ensure any deployment or CI uses secure secret storage
(environment variables or secret manager) and update documentation to show using
placeholders for VITE_SUPABASE_PROJECT_ID, VITE_SUPABASE_PUBLISHABLE_KEY, and
VITE_SUPABASE_URL.

In `@tenders-finder/src/components/ui/chart.tsx`:
- Around line 61-88: The ChartStyle component uses dangerouslySetInnerHTML with
unescaped id and config values, which allows CSS/HTML injection and fails for
special characters; replace the innerHTML approach by producing the stylesheet
as plain text child of the <style> element (i.e., render text content instead of
dangerouslySetInnerHTML), escape the selector id using CSS.escape (or a small
equivalent) when building selectors, and sanitize/validate color strings from
config before including them; update ChartStyle, references to id, THEMES and
colorConfig to use the escaped id and validated color values.

In `@tenders-finder/src/components/ui/form.tsx`:
- Around line 33-54: The useFormField function calls
getFieldState(fieldContext.name, formState) before validating the contexts and
uses a truthy check that never fails; move and strengthen the validation so you
verify both contexts before any access: at the top of useFormField check that
FormFieldContext and FormItemContext values are present and that
fieldContext.name exists (e.g., if fieldContext == null || !fieldContext.name)
and throw the existing error message if not; only after that call
getFieldState(fieldContext.name, formState) and then extract id from itemContext
— reference symbols: useFormField, FormFieldContext, FormItemContext,
useFormContext, getFieldState, formState.

In `@tenders-finder/src/components/ui/progress.tsx`:
- Around line 9-14: The Progress component currently destructures value (and may
receive max) but drops them when rendering ProgressPrimitive.Root, breaking ARIA
and correctness; forward both value and max into ProgressPrimitive.Root (e.g.,
include value={value} and max={max}) and update the indicator transform/width
calculation (currently using hard-coded "100 -") to compute percentage using
(value / max) * 100 so the rendered indicator and aria-valuenow reflect the
actual progress; ensure you still pass ...props for other attributes and use the
same symbols ProgressPrimitive.Root and the indicator element to locate where to
apply the changes.

In `@tenders-finder/src/components/ui/sonner.tsx`:
- Around line 1-4: The file uses React as a type namespace
(React.ComponentProps) but does not import React, causing type errors; add an
import for React (e.g., import * as React from "react") at the top of the module
so the type alias ToasterProps = React.ComponentProps<typeof Sonner> resolves
correctly and other JSX/type usages (ToasterProps, Sonner, toast) continue to
work as expected.

In `@tenders-finder/src/hooks/use-toast.ts`:
- Around line 169-177: The useEffect in use-toast currently depends on "state",
causing repeated registrations of setState into the listeners array; change the
effect dependency from [state] to [] so the listener is registered once on mount
and removed on unmount (ensure the block that pushes setState and the cleanup
that splices listeners by index remain as-is); reference symbols: useEffect,
setState, listeners, state.

In `@tenders-finder/src/integrations/supabase/client.ts`:
- Around line 5-11: Check that SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are
defined before calling createClient in this module (the constants SUPABASE_URL
and SUPABASE_PUBLISHABLE_KEY and the exported supabase from createClient). If
either is missing, throw a clear Error (or call a fail-fast routine) with a
descriptive message that includes which env var is absent so the app fails
immediately instead of passing undefined into createClient.

In `@tenders-finder/src/lib/api/mino.ts`:
- Around line 21-52: startTenderSearch currently calls
supabase.functions.invoke('mino-tender-search') with only { sector } and assumes
a JSON response, but the serverless function requires sector, url (and agentId)
and returns SSE; update startTenderSearch to either (A) iterate your
agent/source list and invoke the function per source passing { sector, url,
agentId } so the server validates and you handle each agent's response, or (B)
replace supabase.functions.invoke with a native fetch/EventSource-based request
to the function URL that sends { sector, url, agentId } and parse the
text/event-stream SSE responses, then call handlers.onAgentUpdate,
handlers.onTenderFound, and handlers.onAgentComplete as events arrive; locate
fixes around startTenderSearch, the supabase.functions.invoke call, and the code
paths that call handlers.onAgentUpdate/onTenderFound/onAgentComplete.

In `@tenders-finder/supabase/functions/discover-tender-links/index.ts`:
- Around line 3-11: The CORS preflight response in the serve handler is missing
Access-Control-Allow-Methods, causing browsers to reject cross-origin POSTs;
update the corsHeaders object (used in the OPTIONS branch where req.method ===
'OPTIONS') to include an 'Access-Control-Allow-Methods' entry listing the
allowed methods (e.g., 'GET, POST, OPTIONS') so the preflight response returns
the required methods header.
- Around line 35-61: Add an explicit timeout to the AI fetch call that requests
the Gemini model: wrap the fetch to
'https://ai.gateway.lovable.dev/v1/chat/completions' (the variable response)
with an AbortController (or use AbortSignal.timeout(10_000) if your Deno runtime
supports it) and pass the controller.signal in the fetch options; ensure the
timeout triggers controller.abort() (or relies on AbortSignal.timeout) after
~10_000ms and handle the abort/timeout error path so the function fails fast
instead of hanging.

In `@tenders-finder/supabase/functions/mino-tender-search/index.ts`:
- Around line 77-96: The fetch to 'https://mino.ai/v1/automation/run-sse' lacks
a timeout; wrap the request in an AbortController with a configurable timeout
(e.g., 10–30s), pass controller.signal to fetch, and clear the timeout once a
response is received; on timeout/AbortError, log including agentId and send an
ERROR SSE via controller.enqueue (using encoder) similar to the existing error
path, then controller.close() and return. Ensure you reference the same
variables used in the diff (apiKey, url, goal, agentId, controller, encoder) and
only abort the specific fetch call so other logic continues to work.

In `@tenders-finder/vite.config.ts`:
- Around line 8-14: The dev server is currently bound to all interfaces via
server.host set to "::", which exposes the Vite dev server beyond localhost;
update the configuration in vite.config.ts by changing server.host to
'localhost' (or make it configurable via an opt-in ENV like VITE_HOST) so the
default remains loopback-only; locate the server block (server: { host: "::",
port: 8080, hmr: { overlay: false } }) and replace the host value or add logic
to read an environment flag to only bind to all interfaces when explicitly
requested.
🟡 Minor comments (26)
tenders-finder/src/components/ui/chart.tsx-249-268 (1)

249-268: ⚠️ Potential issue | 🟡 Minor

Use stable, unique keys for legend entries.
key={item.value} can collide or be undefined.

✅ Suggested fix
-      {payload.map((item) => {
+      {payload.map((item, index) => {
         const key = `${nameKey || item.dataKey || "value"}`;
         const itemConfig = getPayloadConfigFromPayload(config, item, key);
 
         return (
           <div
-            key={item.value}
+            key={`${item.dataKey ?? item.value ?? "legend"}-${index}`}
             className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
           >
             {itemConfig?.icon && !hideIcon ? (
               <itemConfig.icon />
             ) : (
               <div
                 className="h-2 w-2 shrink-0 rounded-[2px]"
                 style={{
                   backgroundColor: item.color,
                 }}
               />
             )}
             {itemConfig?.label}
           </div>
         );
       })}
tenders-finder/src/components/ui/chart.tsx-163-214 (1)

163-214: ⚠️ Potential issue | 🟡 Minor

Render zero values and use stable list keys.
item.value && skips 0, and key={item.dataKey} can be undefined or duplicate.

✅ Suggested fixes
-          {payload.map((item, index) => {
+          {payload.map((item, index) => {
             const key = `${nameKey || item.name || item.dataKey || "value"}`;
             const itemConfig = getPayloadConfigFromPayload(config, item, key);
             const indicatorColor = color || item.payload.fill || item.color;
 
             return (
               <div
-                key={item.dataKey}
+                key={`${item.dataKey ?? key}-${index}`}
                 className={cn(
                   "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
                   indicator === "dot" && "items-center",
                 )}
               >
                 {formatter && item?.value !== undefined && item.name ? (
                   formatter(item.value, item.name, item, index, item.payload)
                 ) : (
                   <>
                     {itemConfig?.icon ? (
                       <itemConfig.icon />
                     ) : (
                       !hideIndicator && (
                         <div
                           className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
                             "h-2.5 w-2.5": indicator === "dot",
                             "w-1": indicator === "line",
                             "w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
                             "my-0.5": nestLabel && indicator === "dashed",
                           })}
                           style={
                             {
                               "--color-bg": indicatorColor,
                               "--color-border": indicatorColor,
                             } as React.CSSProperties
                           }
                         />
                       )
                     )}
                     <div
                       className={cn(
                         "flex flex-1 justify-between leading-none",
                         nestLabel ? "items-end" : "items-center",
                       )}
                     >
                       <div className="grid gap-1.5">
                         {nestLabel ? tooltipLabel : null}
                         <span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
                       </div>
-                      {item.value && (
+                      {item.value !== undefined && item.value !== null && (
                         <span className="font-mono font-medium tabular-nums text-foreground">
                           {item.value.toLocaleString()}
                         </span>
                       )}
                     </div>
                   </>
                 )}
               </div>
             );
           })}
tenders-finder/src/components/ui/sidebar.tsx-22-30 (1)

22-30: ⚠️ Potential issue | 🟡 Minor

Type mismatch for setOpen signature.

The setOpen type on line 25 declares (open: boolean) => void, but the actual implementation (lines 58-71) accepts boolean | ((value: boolean) => boolean) to support the function updater pattern. This inconsistency could cause TypeScript errors if consumers attempt to use the updater pattern.

🔧 Proposed fix
 type SidebarContext = {
   state: "expanded" | "collapsed";
   open: boolean;
-  setOpen: (open: boolean) => void;
+  setOpen: (open: boolean | ((value: boolean) => boolean)) => void;
   openMobile: boolean;
   setOpenMobile: (open: boolean) => void;
   isMobile: boolean;
   toggleSidebar: () => void;
 };
tenders-finder/src/components/ui/sidebar.tsx-363-366 (1)

363-366: ⚠️ Potential issue | 🟡 Minor

Truncated CSS transition property.

Line 364 has transition-[margin,opa] which appears truncated. It should be transition-[margin,opacity] for the opacity transition to work correctly during collapse animations.

🐛 Proposed fix
         className={cn(
-          "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
+          "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
           "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
           className,
         )}
tenders-finder/src/components/ui/sidebar.tsx-534-537 (1)

534-537: ⚠️ Potential issue | 🟡 Minor

Potential SSR hydration mismatch with Math.random().

Using Math.random() inside useMemo with an empty dependency array causes hydration mismatches in SSR/SSG scenarios. The server generates one random value, while the client generates a different one, leading to React hydration warnings or visual flicker.

🔧 Proposed fix using useId or state initialization
+import { useId } from "react";
+
 const SidebarMenuSkeleton = React.forwardRef<
   HTMLDivElement,
   React.ComponentProps<"div"> & {
     showIcon?: boolean;
   }
 >(({ className, showIcon = false, ...props }, ref) => {
-  // Random width between 50 to 90%.
-  const width = React.useMemo(() => {
-    return `${Math.floor(Math.random() * 40) + 50}%`;
-  }, []);
+  // Generate consistent width using useId hash
+  const id = React.useId();
+  const width = React.useMemo(() => {
+    // Simple hash from id to get deterministic value between 50-90%
+    const hash = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
+    return `${(hash % 40) + 50}%`;
+  }, [id]);

Alternatively, if the random appearance is essential and this component won't be SSR'd, you can initialize the width in a useEffect with useState:

 const SidebarMenuSkeleton = React.forwardRef<
   HTMLDivElement,
   React.ComponentProps<"div"> & {
     showIcon?: boolean;
   }
 >(({ className, showIcon = false, ...props }, ref) => {
-  // Random width between 50 to 90%.
-  const width = React.useMemo(() => {
-    return `${Math.floor(Math.random() * 40) + 50}%`;
-  }, []);
+  // Random width between 50 to 90%, initialized client-side only.
+  const [width, setWidth] = React.useState("70%");
+  React.useEffect(() => {
+    setWidth(`${Math.floor(Math.random() * 40) + 50}%`);
+  }, []);
tenders-finder/src/components/ui/button.tsx-39-43 (1)

39-43: ⚠️ Potential issue | 🟡 Minor

Default type="button" when rendering a native button.
Buttons without an explicit type default to type="submit" per the HTML spec, causing unintended form submissions when used inside forms. Extract the type prop and default to "button" for native buttons.

🛠️ Proposed fix
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
-  ({ className, variant, size, asChild = false, ...props }, ref) => {
+  ({ className, variant, size, asChild = false, type, ...props }, ref) => {
     const Comp = asChild ? Slot : "button";
-    return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
+    return (
+      <Comp
+        type={asChild ? undefined : type ?? "button"}
+        className={cn(buttonVariants({ variant, size, className }))}
+        ref={ref}
+        {...props}
+      />
+    );
   },
 );
tenders-finder/src/components/ui/hover-card.tsx-13-23 (1)

13-23: ⚠️ Potential issue | 🟡 Minor

Wrap HoverCardContent in a Portal to prevent overflow clipping and match codebase patterns.

All other overlay components in this codebase (Popover, DropdownMenu, ContextMenu, Select, Dialog, etc.) use Portal wrappers for their content. This aligns with Radix's documented approach for preventing content from being clipped by parent overflow: hidden contexts.

Proposed fix
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+  <HoverCardPrimitive.Portal>
    <HoverCardPrimitive.Content
      ref={ref}
      align={align}
      sideOffset={sideOffset}
      className={cn(
        "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
        className,
      )}
      {...props}
    />
+  </HoverCardPrimitive.Portal>
));
tenders-finder/src/components/ui/card.tsx-17-22 (1)

17-22: ⚠️ Potential issue | 🟡 Minor

Fix ref element type mismatch for CardTitle.

CardTitle renders an h3 element but the ref generic is typed as HTMLParagraphElement. This should be HTMLHeadingElement to match the actual rendered element and ensure correct type safety.

🛠️ Proposed fix
-const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
+const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
tenders-finder/supabase/functions/mino-tender-search/index.ts-169-171 (1)

169-171: ⚠️ Potential issue | 🟡 Minor

Silent error swallowing may hide parsing issues.

Catching and ignoring JSON parsing errors without any indication could make debugging difficult when the Mino API returns unexpected data formats.

Suggested improvement
                   } catch (e) {
-                    // Ignore parsing errors
+                    // Log parsing errors for debugging but continue processing
+                    console.warn(`[${agentId}] Failed to parse SSE data:`, line.slice(6));
                   }
tenders-finder/src/components/ui/alert.tsx-29-34 (1)

29-34: ⚠️ Potential issue | 🟡 Minor

Ref type mismatch: renders <h5> but typed as HTMLParagraphElement.

The component renders an <h5> element but the ref is typed as HTMLParagraphElement. This should be HTMLHeadingElement for type correctness.

Suggested fix
-const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
+const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
tenders-finder/src/components/tender/CompareModal.tsx-90-103 (1)

90-103: ⚠️ Potential issue | 🟡 Minor

Dead code: officialTenderUrl field check will never match.

The condition field.key === 'officialTenderUrl' will never be true because officialTenderUrl is not included in the COMPARE_FIELDS array. This special-case rendering is unreachable. The official URL is already handled correctly in the dedicated row at lines 108-128.

Suggested fix - simplify the cell rendering
                         {tenders.map((tender) => (
                           <td
                             key={`${tender.id}-${field.key}`}
                             className="p-3 text-foreground border border-border"
                           >
-                            {field.key === 'officialTenderUrl' ? (
-                              <a
-                                href={tender[field.key]}
-                                target="_blank"
-                                rel="noopener noreferrer"
-                                className="text-primary hover:underline inline-flex items-center gap-1"
-                              >
-                                View <ExternalLink className="w-3 h-3" />
-                              </a>
-                            ) : (
-                              <span className="line-clamp-3">
-                                {tender[field.key] || 'N/A'}
-                              </span>
-                            )}
+                            <span className="line-clamp-3">
+                              {tender[field.key] || 'N/A'}
+                            </span>
                           </td>
                         ))}
tenders-finder/src/components/ui/alert.tsx-36-41 (1)

36-41: ⚠️ Potential issue | 🟡 Minor

Ref type mismatch: renders <div> but typed as HTMLParagraphElement.

The component renders a <div> element but the ref is typed as HTMLParagraphElement. This should be HTMLDivElement for type correctness.

Suggested fix
-const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
+const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
tenders-finder/src/components/tender/CompareModal.tsx-27-31 (1)

27-31: ⚠️ Potential issue | 🟡 Minor

Exit animations won't trigger due to early return.

The early return null on line 28 prevents AnimatePresence from detecting the exit and running the exit animations defined on the motion elements. When isOpen becomes false, the component immediately returns null without giving AnimatePresence a chance to animate out.

Suggested fix
 export function CompareModal({ isOpen, onClose, tenders }: CompareModalProps) {
-  if (!isOpen) return null;
-
   return (
     <AnimatePresence>
-      <motion.div
+      {isOpen && (
+        <motion.div
           initial={{ opacity: 0 }}
           animate={{ opacity: 1 }}
           exit={{ opacity: 0 }}
           // ... rest of component
-      </motion.div>
+        </motion.div>
+      )}
     </AnimatePresence>
   );
 }
tenders-finder/src/components/ui/breadcrumb.tsx-69-80 (1)

69-80: ⚠️ Potential issue | 🟡 Minor

Fix displayName typo for BreadcrumbEllipsis

The displayName string is misspelled, which makes DevTools inspection harder.

🛠️ Suggested fix
-BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
+BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";
tenders-finder/src/components/ui/carousel.tsx-70-81 (1)

70-81: ⚠️ Potential issue | 🟡 Minor

Handle arrow keys for vertical orientation

ArrowUp/ArrowDown should control vertical carousels; current logic only reacts to left/right.

🛠️ Suggested update
-    const handleKeyDown = React.useCallback(
-      (event: React.KeyboardEvent<HTMLDivElement>) => {
-        if (event.key === "ArrowLeft") {
-          event.preventDefault();
-          scrollPrev();
-        } else if (event.key === "ArrowRight") {
-          event.preventDefault();
-          scrollNext();
-        }
-      },
-      [scrollPrev, scrollNext],
-    );
+    const handleKeyDown = React.useCallback(
+      (event: React.KeyboardEvent<HTMLDivElement>) => {
+        const isHorizontal = orientation === "horizontal";
+        if (
+          (isHorizontal && event.key === "ArrowLeft") ||
+          (!isHorizontal && event.key === "ArrowUp")
+        ) {
+          event.preventDefault();
+          scrollPrev();
+        } else if (
+          (isHorizontal && event.key === "ArrowRight") ||
+          (!isHorizontal && event.key === "ArrowDown")
+        ) {
+          event.preventDefault();
+          scrollNext();
+        }
+      },
+      [orientation, scrollPrev, scrollNext],
+    );
tenders-finder/src/components/ui/command.tsx-70-74 (1)

70-74: ⚠️ Potential issue | 🟡 Minor

Preserve base styles in CommandEmpty

Props are spread after className, so consumer className can wipe default styles. Merge with cn.

🛠️ Suggested fix
-const CommandEmpty = React.forwardRef<
-  React.ElementRef<typeof CommandPrimitive.Empty>,
-  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
->((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
+const CommandEmpty = React.forwardRef<
+  React.ElementRef<typeof CommandPrimitive.Empty>,
+  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
+>(({ className, ...props }, ref) => (
+  <CommandPrimitive.Empty
+    ref={ref}
+    className={cn("py-6 text-center text-sm", className)}
+    {...props}
+  />
+));
tenders-finder/src/components/ui/carousel.tsx-91-102 (1)

91-102: ⚠️ Potential issue | 🟡 Minor

Remove both event listener registrations on cleanup

The effect registers both reInit and select listeners, but cleanup only removes select. According to Embla's API, listeners persist after reInit() and must be explicitly removed or they'll accumulate on subsequent effect runs.

Suggested fix
      return () => {
        api?.off("select", onSelect);
+       api?.off("reInit", onSelect);
      };

Note: This same pattern appears in anime-watch-hub/components/ui/carousel.tsx and summer-school-finder/src/components/ui/carousel.tsx.

tenders-finder/src/hooks/useTenderSearch.ts-95-107 (1)

95-107: ⚠️ Potential issue | 🟡 Minor

Explicitly handle null response body to avoid agent getting stuck in "connecting" state

If response.body is null (which can occur even with response.ok === true in certain HTTP scenarios), the entire streaming processing block is skipped and the agent never leaves the "connecting" status. This should be treated as an error condition.

🛠️ Suggested handling
-        const reader = response.body?.getReader();
-        const decoder = new TextDecoder();
-        let buffer = '';
-
-        if (reader) {
-          while (true) {
+        const reader = response.body?.getReader();
+        if (!reader) {
+          throw new Error('Streaming response body is unavailable');
+        }
+        const decoder = new TextDecoder();
+        let buffer = '';
+
+        while (true) {
           const { done, value } = await reader.read();
           if (done) break;
@@
-          }
-        }
+        }

The error will be caught by the existing error handler at line 216 and properly set the agent status to 'error'.

tenders-finder/index.html-6-19 (1)

6-19: ⚠️ Potential issue | 🟡 Minor

Replace placeholder metadata before release.

Line 6-19 still contain TODOs and placeholder branding (title, description, OG/Twitter meta, and image). These should be updated to the actual app name and assets to avoid incorrect previews/SEO.

tenders-finder/src/components/ui/menubar.tsx-185-189 (1)

185-189: ⚠️ Potential issue | 🟡 Minor

Fix displayName casing for MenubarShortcut.

Line 188 uses displayname (lowercase n), but React expects displayName. All other components in this file correctly use displayName.

🛠️ Suggested fix
-MenubarShortcut.displayname = "MenubarShortcut";
+MenubarShortcut.displayName = "MenubarShortcut";
tenders-finder/README.md-1-39 (1)

1-39: ⚠️ Potential issue | 🟡 Minor

Resolve markdownlint warnings (duplicate heading, bare URLs, bold-as-heading).
Consider converting bold section labels to headings and using Markdown links for URLs to clear MD034/MD024/MD036.

✅ Example formatting fixes
-**Live Link**: https://tender-scout-singapore.lovable.app
+## Live Link
+[Tender Scout Singapore](https://tender-scout-singapore.lovable.app)

-## What This Project Is -
+## What This Project Is

-**AI-based Link Discovery**
+### AI-based Link Discovery

Also applies to: 84-89

tenders-finder/README.md-6-27 (1)

6-27: ⚠️ Potential issue | 🟡 Minor

Fix the project description — it describes summer schools, not tenders.
This section should describe the Government Tender Finder for Singapore rather than a summer school tool.

✏️ Example edit
-This project is an AI-powered summer school discovery and comparison tool that automatically finds, scans, and extracts information from official summer school websites worldwide.
+This project is an AI-powered government tender discovery and comparison tool that finds, scans, and extracts tenders from official Singapore procurement portals.
tenders-finder/src/components/tender/AgentPreviewGrid.tsx-22-31 (1)

22-31: ⚠️ Potential issue | 🟡 Minor

Sorting bug: priority 0 gets overwritten by ||.
Use ?? so "searching" stays at the highest priority.

✅ Fix priority fallback
-    return (priority[a.status] || 5) - (priority[b.status] || 5);
+    return (priority[a.status] ?? 5) - (priority[b.status] ?? 5);
tenders-finder/src/hooks/use-toast.ts-140-144 (1)

140-144: ⚠️ Potential issue | 🟡 Minor

Type mismatch in update function signature.

The update function accepts ToasterToast but should accept Partial<ToasterToast> to match the UPDATE_TOAST action type defined on lines 37-39:

🐛 Proposed fix
-  const update = (props: ToasterToast) =>
+  const update = (props: Partial<ToasterToast>) =>
     dispatch({
       type: "UPDATE_TOAST",
       toast: { ...props, id },
     });
tenders-finder/src/components/tender/AgentPreviewCard.tsx-210-215 (1)

210-215: ⚠️ Potential issue | 🟡 Minor

Potential runtime error if agent.url is malformed.

new URL(agent.url).hostname will throw a TypeError if agent.url is empty, malformed, or not a valid URL, crashing the component.

🛡️ Proposed defensive fix
       {/* Footer */}
       <div className="px-3 py-2 bg-gradient-to-r from-muted/30 to-muted/20 border-t border-border">
         <p className="text-xs text-muted-foreground truncate flex items-center gap-1.5">
           <Globe className="w-3 h-3 flex-shrink-0" />
-          {new URL(agent.url).hostname}
+          {(() => {
+            try {
+              return new URL(agent.url).hostname;
+            } catch {
+              return agent.url;
+            }
+          })()}
         </p>
       </div>

Or extract to a helper function for cleaner code.

tenders-finder/src/components/tender/LiveBrowserModal.tsx-22-29 (1)

22-29: ⚠️ Potential issue | 🟡 Minor

Exit animations won't trigger due to early return.

The early return if (!isOpen) return null on line 22 bypasses AnimatePresence, so the exit animations defined on lines 29 and 36 will never run. The modal will disappear instantly instead of animating out.

Move the conditional inside AnimatePresence:

🐛 Proposed fix
-  if (!isOpen) return null;
-
   return (
     <AnimatePresence>
-      <motion.div
+      {isOpen && (
+        <motion.div
-        initial={{ opacity: 0 }}
-        animate={{ opacity: 1 }}
-        exit={{ opacity: 0 }}
+          initial={{ opacity: 0 }}
+          animate={{ opacity: 1 }}
+          exit={{ opacity: 0 }}
+          ...
+        </motion.div>
+      )}
     </AnimatePresence>
   );
🧹 Nitpick comments (25)
tenders-finder/src/components/ui/navigation-menu.tsx (1)

47-47: Nitpick: Redundant "group" class.

The "group" class is already included in navigationMenuTriggerStyle (line 38), so adding it again here is unnecessary.

♻️ Suggested fix
-    className={cn(navigationMenuTriggerStyle(), "group", className)}
+    className={cn(navigationMenuTriggerStyle(), className)}
tenders-finder/src/components/ui/sheet.tsx (1)

40-41: Minor: Extra whitespace in class string.

There's a double space between w-3/4 and border-l.

🧹 Suggested fix
        right:
-          "inset-y-0 right-0 h-full w-3/4  border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+          "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
tenders-finder/src/components/ui/input-otp.tsx (1)

27-29: Consider adding defensive access for the slot context.

The destructuring of inputOTPContext.slots[index] assumes the context is always properly initialized and the index is within bounds. While this follows standard ShadCN patterns (assuming correct usage within the OTPInput hierarchy), adding a defensive check could prevent cryptic errors if misused.

🛡️ Optional defensive check
 >(({ index, className, ...props }, ref) => {
   const inputOTPContext = React.useContext(OTPInputContext);
-  const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
+  const slot = inputOTPContext?.slots?.[index];
+  const { char, hasFakeCaret, isActive } = slot ?? {};
tenders-finder/src/components/ui/calendar.tsx (1)

10-51: Component implementation follows ShadCN UI patterns well.

The classNames structure correctly uses react-day-picker v8's snake_case naming convention, and spreading ...classNames at the end allows consumers to override defaults. The responsive layout with sm: breakpoints is appropriate.

One minor note on lines 45-46: the _props parameter is collected but unused. If the props from DayPicker aren't needed, this could be simplified.

♻️ Optional: Simplify unused props
       components={{
-        IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
-        IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
+        IconLeft: () => <ChevronLeft className="h-4 w-4" />,
+        IconRight: () => <ChevronRight className="h-4 w-4" />,
       }}
tenders-finder/src/components/ui/sidebar.tsx (1)

67-68: Consider adding SameSite attribute to cookie.

The cookie is set without an explicit SameSite attribute. While modern browsers default to Lax, being explicit improves clarity and ensures consistent behavior across all browsers.

♻️ Proposed fix
-      document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
+      document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}; SameSite=Lax`;
tenders-finder/src/components/ui/progress.tsx (1)

15-18: Use max and clamp value to avoid overflow.

The indicator math is hard-coded to 100, so custom max props or out-of-range values will misrender. Consider clamping and scaling by max.

♻️ Proposed refactor
-const Progress = React.forwardRef<
+const Progress = React.forwardRef<
   React.ElementRef<typeof ProgressPrimitive.Root>,
   React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
->(({ className, value, ...props }, ref) => (
+>(({ className, value, max = 100, ...props }, ref) => (
   <ProgressPrimitive.Root
     ref={ref}
     className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
     {...props}
   >
     <ProgressPrimitive.Indicator
       className="h-full w-full flex-1 bg-primary transition-all"
-      style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
+      style={{
+        transform: `translateX(-${
+          100 - ((Math.min(Math.max(value ?? 0, 0), max) / max) * 100)
+        }%)`,
+      }}
     />
   </ProgressPrimitive.Root>
 ));
tenders-finder/.gitignore (1)

1-24: Consider adding .env* patterns to prevent accidental secret commits.

Given that this project integrates with Supabase (per the PR summary), environment files containing API keys and secrets are likely in use. Adding patterns like .env, .env.local, .env*.local would provide defense-in-depth against accidental secret exposure.

Suggested addition
 node_modules
 dist
 dist-ssr
 *.local
+
+# Environment files
+.env
+.env.*
+!.env.example
tenders-finder/src/hooks/use-mobile.tsx (1)

8-16: Consider using mql.matches in the change handler for consistency.

The onChange handler recalculates using window.innerWidth instead of using mql.matches. While functionally equivalent, using mql.matches is more consistent with the media query approach and avoids potential edge-case discrepancies.

Suggested refinement
   React.useEffect(() => {
     const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
     const onChange = () => {
-      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+      setIsMobile(mql.matches);
     };
     mql.addEventListener("change", onChange);
-    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+    setIsMobile(mql.matches);
     return () => mql.removeEventListener("change", onChange);
   }, []);
tenders-finder/supabase/functions/discover-tender-links/index.ts (1)

20-32: Deduplicate the default links list to prevent drift.
The same list appears in three branches; centralizing it reduces maintenance risk.

♻️ Suggested refactor
 const corsHeaders = {
   "Access-Control-Allow-Origin": "*",
   "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
 };
+
+const DEFAULT_LINKS = [
+  { url: "https://www.gebiz.gov.sg/", name: "GeBIZ" },
+  { url: "https://www.tendersontime.com/singapore-tenders/", name: "Tenders On Time" },
+  { url: "https://www.biddetail.com/singapore-tenders", name: "Bid Detail" },
+  { url: "https://www.tendersinfo.com/global-singapore-tenders.php", name: "Tenders Info" },
+  { url: "https://www.globaltenders.com/government-tenders-singapore", name: "Global Tenders" },
+] as const;
-        JSON.stringify({
-          success: true,
-          links: [
-            { url: 'https://www.gebiz.gov.sg/', name: 'GeBIZ' },
-            { url: 'https://www.tendersontime.com/singapore-tenders/', name: 'Tenders On Time' },
-            { url: 'https://www.biddetail.com/singapore-tenders', name: 'Bid Detail' },
-            { url: 'https://www.tendersinfo.com/global-singapore-tenders.php', name: 'Tenders Info' },
-            { url: 'https://www.globaltenders.com/government-tenders-singapore', name: 'Global Tenders' },
-          ]
-        }),
+        JSON.stringify({ success: true, links: DEFAULT_LINKS }),
-      links = [
-        { url: 'https://www.gebiz.gov.sg/', name: 'GeBIZ' },
-        { url: 'https://www.tendersontime.com/singapore-tenders/', name: 'Tenders On Time' },
-        { url: 'https://www.biddetail.com/singapore-tenders', name: 'Bid Detail' },
-        { url: 'https://www.tendersinfo.com/global-singapore-tenders.php', name: 'Tenders Info' },
-        { url: 'https://www.globaltenders.com/government-tenders-singapore', name: 'Global Tenders' },
-      ];
+      links = [...DEFAULT_LINKS];
-        links: [
-          { url: 'https://www.gebiz.gov.sg/', name: 'GeBIZ' },
-          { url: 'https://www.tendersontime.com/singapore-tenders/', name: 'Tenders On Time' },
-          { url: 'https://www.biddetail.com/singapore-tenders', name: 'Bid Detail' },
-          { url: 'https://www.tendersinfo.com/global-singapore-tenders.php', name: 'Tenders Info' },
-          { url: 'https://www.globaltenders.com/government-tenders-singapore', name: 'Global Tenders' },
-        ]
+        links: DEFAULT_LINKS

Also applies to: 81-87, 102-108

tenders-finder/src/components/NavLink.tsx (1)

11-22: Support className callbacks for full NavLink parity.

React Router v6's NavLink accepts className as either a string or a callback function receiving { isActive, isPending } and returning a string. The current wrapper restricts className to string only, preventing consumers from using the callback form.

To support the full NavLink API, update the interface to accept NavLinkProps["className"] and handle both string and function forms:

Proposed implementation
-interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
-  className?: string;
+interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
+  className?: NavLinkProps["className"];
   activeClassName?: string;
   pendingClassName?: string;
 }
@@
-        className={({ isActive, isPending }) =>
-          cn(className, isActive && activeClassName, isPending && pendingClassName)
-        }
+        className={({ isActive, isPending }) => {
+          const baseClassName =
+            typeof className === "function" ? className({ isActive, isPending }) : className;
+          return cn(baseClassName, isActive && activeClassName, isPending && pendingClassName);
+        }}
tenders-finder/supabase/functions/mino-tender-search/index.ts (2)

102-175: Consider explicit reader cleanup on errors.

If an error occurs during stream processing, the reader should be explicitly released to avoid resource leaks in long-running scenarios.

Suggested pattern
           if (reader) {
+            try {
               while (true) {
                 const { done, value } = await reader.read();
                 if (done) break;
                 // ... processing ...
               }
+            } finally {
+              reader.releaseLock();
+            }
           }

1-4: Wildcard CORS origin for production consideration.

Using 'Access-Control-Allow-Origin': '*' is acceptable for development but should be restricted to specific origins in production deployments.

tenders-finder/src/components/tender/CompareModal.tsx (1)

32-44: Consider adding keyboard accessibility.

The modal lacks keyboard escape handling and focus management. Users should be able to close the modal with the Escape key, and focus should be trapped within the modal while open.

Suggested addition
// Add useEffect for escape key handling
React.useEffect(() => {
  const handleEscape = (e: KeyboardEvent) => {
    if (e.key === 'Escape') onClose();
  };
  if (isOpen) {
    document.addEventListener('keydown', handleEscape);
    return () => document.removeEventListener('keydown', handleEscape);
  }
}, [isOpen, onClose]);

Consider using a Dialog primitive from Radix UI for built-in accessibility features.

tenders-finder/src/hooks/useTenderSearch.ts (1)

42-55: Fail fast when Supabase env vars are missing

If the env vars are absent, the fetch URL becomes invalid and errors are opaque. A guard gives a clearer failure mode.

🛠️ Suggested guard
 const startSearch = useCallback(async (sector: Sector, links: string[]) => {
+    if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
+      const erroredAgents = createAgentsFromLinks(links).map(agent => ({
+        ...agent,
+        status: 'error' as const,
+        message: 'Missing Supabase configuration',
+      }));
+      setState(prev => ({
+        ...prev,
+        isSearching: false,
+        selectedSector: sector,
+        agents: erroredAgents,
+        tenders: [],
+        selectedTenders: new Set(),
+      }));
+      return;
+    }
     // Initialize agents from provided links
tenders-finder/tsconfig.json (1)

2-8: Root config's compiler options are not inherited; consolidate using extends to avoid duplication.

Neither tsconfig.app.json nor tsconfig.node.json extends this root config. As a result, the baseUrl and paths options defined here are unused. tsconfig.app.json redefines them directly, while tsconfig.node.json doesn't need them. To eliminate duplication and leverage shared configuration, have tsconfig.app.json extend this root config and remove the redundant baseUrl/paths definitions.

tenders-finder/src/pages/NotFound.tsx (1)

1-18: Prefer <Link> for client-side navigation.

Line 16-18 uses a plain anchor, which forces a full page reload and bypasses SPA routing. Using <Link> preserves client-side navigation and app state.

🔁 Suggested change
-import { useLocation } from "react-router-dom";
+import { Link, useLocation } from "react-router-dom";
@@
-        <a href="/" className="text-primary underline hover:text-primary/90">
+        <Link to="/" className="text-primary underline hover:text-primary/90">
           Return to Home
-        </a>
+        </Link>
tenders-finder/package.json (1)

2-2: Project name doesn't match directory name.

The package name vite_react_shadcn_ts appears to be a template placeholder. Consider renaming it to tenders-finder to match the project directory and improve clarity.

-  "name": "vite_react_shadcn_ts",
+  "name": "tenders-finder",
tenders-finder/src/components/tender/TenderResultCard.tsx (1)

15-27: Consider adding keyboard accessibility for the clickable card.

The card is clickable via onClick but lacks keyboard support. Users navigating with keyboards won't be able to select tenders. Consider adding role="button", tabIndex={0}, and an onKeyDown handler.

     <motion.div
       initial={{ opacity: 0, y: 20 }}
       animate={{ opacity: 1, y: 0 }}
       whileHover={{ scale: 1.01 }}
       onClick={onToggleSelect}
+      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleSelect(); }}}
+      role="button"
+      tabIndex={0}
       className={cn(
tenders-finder/tsconfig.app.json (1)

18-23: TypeScript strict checks are disabled, reducing type safety.

All linting options are turned off, which significantly reduces TypeScript's ability to catch bugs at compile time:

  • strict: false disables all strict type-checking
  • noImplicitAny: false allows untyped variables
  • noFallthroughCasesInSwitch: false can lead to accidental switch fallthrough bugs

For a new project, consider enabling at least strict: true to catch issues early. You can incrementally enable other options later.

     /* Linting */
-    "strict": false,
-    "noUnusedLocals": false,
-    "noUnusedParameters": false,
-    "noImplicitAny": false,
-    "noFallthroughCasesInSwitch": false,
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
tenders-finder/src/App.css (1)

1-42: This appears to be Vite template boilerplate CSS.

This file contains default Vite template styles (logo animations, .read-the-docs class) that don't seem relevant to a Tenders Finder application. Since the project uses Tailwind CSS for styling, consider removing this file or cleaning it up to only include styles actually used by the app.

tenders-finder/src/components/ui/accordion.tsx (1)

41-47: Apply className to the Content wrapper (not just the inner div).
This aligns with expected API behavior for styling the Radix content element.

♻️ Suggested adjustment
   <AccordionPrimitive.Content
     ref={ref}
-    className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
+    className={cn(
+      "overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
+      className
+    )}
     {...props}
   >
-    <div className={cn("pb-4 pt-0", className)}>{children}</div>
+    <div className="pb-4 pt-0">{children}</div>
   </AccordionPrimitive.Content>
tenders-finder/src/components/tender/TenderResultsList.tsx (1)

59-74: Consider capping staggered animation delay for large result sets.

The staggered delay index * 0.05 will cause the last items to appear very late if the result set is large (e.g., 100 tenders = 5 seconds delay for the last card). Consider capping the delay:

♻️ Suggested improvement
             <motion.div
               key={tender.id}
               initial={{ opacity: 0, y: 20 }}
               animate={{ opacity: 1, y: 0 }}
-              transition={{ delay: index * 0.05 }}
+              transition={{ delay: Math.min(index * 0.05, 0.5) }}
             >
tenders-finder/src/pages/Index.tsx (1)

127-133: CompareButton may show inappropriately in non-search views.

The CompareButton is rendered whenever tenders.length > 0, but this condition doesn't account for the current view state. If tenders exist from a previous search, the button will appear even in the selector or config views until resetSearch() is called.

Consider adding a view check:

♻️ Suggested fix
       {/* Compare Button - only show when we have results */}
-      {tenders.length > 0 && (
+      {view === 'search' && tenders.length > 0 && (
         <CompareButton
           selectedCount={selectedTenders.size}
           onCompare={handleCompare}
         />
       )}
tenders-finder/src/components/tender/LinkConfigPage.tsx (2)

56-65: Extract duplicated fallback URLs to a constant.

The same fallback URLs are repeated three times (lines 58-64, 83-89, 104-110). This violates DRY and increases maintenance burden if the fallback list needs updating.

♻️ Proposed refactor
+const FALLBACK_TENDER_LINKS = [
+  'https://www.gebiz.gov.sg/',
+  'https://www.tendersontime.com/singapore-tenders/',
+  'https://www.biddetail.com/singapore-tenders',
+  'https://www.tendersinfo.com/global-singapore-tenders.php',
+  'https://www.globaltenders.com/government-tenders-singapore',
+];
+
 export function LinkConfigPage({ sector, onBack, onStartSearch }: LinkConfigPageProps) {
   // ...
   
   const fetchAILinks = async (count: number = 5): Promise<string[]> => {
     try {
       // ...
     } catch (error) {
       console.error('Error fetching AI links:', error);
-      return [
-        'https://www.gebiz.gov.sg/',
-        // ... repeated URLs
-      ];
+      return FALLBACK_TENDER_LINKS;
     }
   };

Then use FALLBACK_TENDER_LINKS in all three locations.

Also applies to: 83-91, 104-111


52-54: Consider adding type safety for API response.

The any type cast on line 53 bypasses TypeScript's type checking. Consider defining an interface for the expected response shape.

♻️ Suggested improvement
+interface DiscoverLinksResponse {
+  links: Array<{ url: string }>;
+}
+
 const fetchAILinks = async (count: number = 5): Promise<string[]> => {
   try {
-    const { data, error } = await supabase.functions.invoke('discover-tender-links', {
+    const { data, error } = await supabase.functions.invoke<DiscoverLinksResponse>('discover-tender-links', {
       body: { sector, limit: count }
     });
     
     if (error) throw error;
     
     if (data?.links) {
-      return data.links.map((link: any) => link.url);
+      return data.links.map((link) => link.url);
     }

Comment on lines +1 to +3
VITE_SUPABASE_PROJECT_ID="dksfgbuuciwhicmpdkys"
VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRrc2ZnYnV1Y2l3aGljbXBka3lzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjkyNTE5NDIsImV4cCI6MjA4NDgyNzk0Mn0.vgKY9Gc5qtZOP5ZZOzGvKihqLgWXrwMSbTYYpSCFpEg"
VITE_SUPABASE_URL="https://dksfgbuuciwhicmpdkys.supabase.co"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove committed secrets from .env and rotate the exposed key.
The publishable key (JWT) is in version control, which can enable unauthorized access. Replace with placeholders, add .env to .gitignore, and rotate/revoke the exposed key in Supabase.

🔒 Proposed fix (keep only placeholders in repo)
-VITE_SUPABASE_PROJECT_ID="dksfgbuuciwhicmpdkys"
-VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRrc2ZnYnV1Y2l3aGljbXBka3lzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjkyNTE5NDIsImV4cCI6MjA4NDgyNzk0Mn0.vgKY9Gc5qtZOP5ZZOzGvKihqLgWXrwMSbTYYpSCFpEg"
-VITE_SUPABASE_URL="https://dksfgbuuciwhicmpdkys.supabase.co"
+VITE_SUPABASE_PROJECT_ID=""
+VITE_SUPABASE_PUBLISHABLE_KEY=""
+VITE_SUPABASE_URL=""
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 1-1: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 2-2: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 3-3: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)

🪛 Gitleaks (8.30.0)

[high] 2-2: Uncovered a JSON Web Token, which may lead to unauthorized access to web applications and sensitive user data.

(jwt)

🤖 Prompt for AI Agents
In `@tenders-finder/.env` around lines 1 - 3, The committed .env contains leaked
secrets (VITE_SUPABASE_PROJECT_ID, VITE_SUPABASE_PUBLISHABLE_KEY,
VITE_SUPABASE_URL); remove these real values and replace them with placeholder
values in the file, add .env to .gitignore, and rotate/revoke the exposed
publishable key in Supabase immediately; ensure any deployment or CI uses secure
secret storage (environment variables or secret manager) and update
documentation to show using placeholders for VITE_SUPABASE_PROJECT_ID,
VITE_SUPABASE_PUBLISHABLE_KEY, and VITE_SUPABASE_URL.

Comment on lines +61 to +88
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);

if (!colorConfig.length) {
return null;
}

return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid dangerouslySetInnerHTML and escape the chart id in selectors.
config values and id can be untrusted; current approach risks HTML/style injection and breaks when id contains special characters. Prefer rendering CSS as text content and escape the selector. Also consider validating color strings if config can come from user input.

🔧 Suggested safer rendering + selector escaping
-  return (
-    <style
-      dangerouslySetInnerHTML={{
-        __html: Object.entries(THEMES)
-          .map(
-            ([theme, prefix]) => `
-${prefix} [data-chart=${id}] {
-${colorConfig
-  .map(([key, itemConfig]) => {
-    const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
-    return color ? `  --color-${key}: ${color};` : null;
-  })
-  .join("\n")}
-}
-`,
-          )
-          .join("\n"),
-      }}
-    />
-  );
+  const escapedId = CSS.escape(id);
+  const cssText = Object.entries(THEMES)
+    .map(
+      ([theme, prefix]) => `
+${prefix} [data-chart="${escapedId}"] {
+${colorConfig
+  .map(([key, itemConfig]) => {
+    const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
+    return color ? `  --color-${key}: ${color};` : null;
+  })
+  .join("\n")}
+}
+`,
+    )
+    .join("\n");
+
+  return <style>{cssText}</style>;
🧰 Tools
🪛 ast-grep (0.40.5)

[warning] 69-69: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🤖 Prompt for AI Agents
In `@tenders-finder/src/components/ui/chart.tsx` around lines 61 - 88, The
ChartStyle component uses dangerouslySetInnerHTML with unescaped id and config
values, which allows CSS/HTML injection and fails for special characters;
replace the innerHTML approach by producing the stylesheet as plain text child
of the <style> element (i.e., render text content instead of
dangerouslySetInnerHTML), escape the selector id using CSS.escape (or a small
equivalent) when building selectors, and sanitize/validate color strings from
config before including them; update ChartStyle, references to id, THEMES and
colorConfig to use the escaped id and validated color values.

Comment on lines +33 to +54
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();

const fieldState = getFieldState(fieldContext.name, formState);

if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}

const { id } = itemContext;

return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Context validation check is ineffective and misplaced.

Two issues with the error handling:

  1. The check if (!fieldContext) at Line 40 happens after getFieldState(fieldContext.name, formState) is already called at Line 38, which will fail or produce undefined behavior when fieldContext.name is undefined.

  2. The check will never throw because the context default value is {} as FormFieldContextValue (Line 18), and an empty object is truthy.

🐛 Proposed fix: Move and fix the context validation
 const useFormField = () => {
   const fieldContext = React.useContext(FormFieldContext);
   const itemContext = React.useContext(FormItemContext);
+
+  if (!fieldContext.name) {
+    throw new Error("useFormField should be used within <FormField>");
+  }
+
   const { getFieldState, formState } = useFormContext();
 
   const fieldState = getFieldState(fieldContext.name, formState);
 
-  if (!fieldContext) {
-    throw new Error("useFormField should be used within <FormField>");
-  }
-
   const { id } = itemContext;
 
   return {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
if (!fieldContext.name) {
throw new Error("useFormField should be used within <FormField>");
}
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
🤖 Prompt for AI Agents
In `@tenders-finder/src/components/ui/form.tsx` around lines 33 - 54, The
useFormField function calls getFieldState(fieldContext.name, formState) before
validating the contexts and uses a truthy check that never fails; move and
strengthen the validation so you verify both contexts before any access: at the
top of useFormField check that FormFieldContext and FormItemContext values are
present and that fieldContext.name exists (e.g., if fieldContext == null ||
!fieldContext.name) and throw the existing error message if not; only after that
call getFieldState(fieldContext.name, formState) and then extract id from
itemContext — reference symbols: useFormField, FormFieldContext,
FormItemContext, useFormContext, getFieldState, formState.

Comment on lines +9 to +14
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props}
>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n tenders-finder/src/components/ui/progress.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 998


🌐 Web query:

Radix UI react-progress ProgressPrimitive Root value prop ARIA documentation

💡 Result:

ProgressPrimitive.Root (@radix-ui/react-progress) — value + ARIA

  • value prop type: number | null (no default). Setting value={null} puts the progress into an indeterminate state. [1]
  • max prop type: number (no default). Used as the progress bar’s maximum (i.e., the upper bound for the current value). [1]
  • ARIA mapping / behavior (progressbar role):
    • Radix Progress “adheres to the progressbar role requirements.” [1]
    • For a determinate progressbar, ARIA uses:
      • aria-valuenow = current numeric value (your value) [2]
      • aria-valuemin / aria-valuemax define the range; if omitted for role="progressbar", they default to 0 and 100 respectively. [3]
    • For an indeterminate progressbar, do not set aria-valuenow (so value={null} is the right model). [2]
    • A progressbar must have an accessible name via aria-label or aria-labelledby. [3]

Sources: Radix Progress docs (API + accessibility note) [1], MDN on aria-valuenow (incl. indeterminate guidance) [2], MDN on progressbar role defaults + naming requirement [3].


Forward value and max to ProgressPrimitive.Root (currently dropped).

value is destructured but not passed to Root, breaking ARIA semantics—the progressbar won't have aria-valuenow set, violating accessibility requirements. Additionally, the indicator transform hard-codes 100 - which assumes max is always 100; if a different max is passed, the progress calculation becomes incorrect.

✅ Proposed fix
  <ProgressPrimitive.Root
    ref={ref}
    className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
+   value={value}
+   max={props.max || 100}
    {...props}
  >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props}
>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
value={value}
max={props.max || 100}
{...props}
>
🤖 Prompt for AI Agents
In `@tenders-finder/src/components/ui/progress.tsx` around lines 9 - 14, The
Progress component currently destructures value (and may receive max) but drops
them when rendering ProgressPrimitive.Root, breaking ARIA and correctness;
forward both value and max into ProgressPrimitive.Root (e.g., include
value={value} and max={max}) and update the indicator transform/width
calculation (currently using hard-coded "100 -") to compute percentage using
(value / max) * 100 so the rendered indicator and aria-valuenow reflect the
actual progress; ensure you still pass ...props for other attributes and use the
same symbols ProgressPrimitive.Root and the indicator element to locate where to
apply the changes.

Comment on lines +1 to +4
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";

type ToasterProps = React.ComponentProps<typeof Sonner>;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, verify the actual file content
cat -n tenders-finder/src/components/ui/sonner.tsx | head -20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1020


🏁 Script executed:

# Check tsconfig settings for UMD globals and jsx settings
find . -name "tsconfig*.json" -o -name "tsconfig*.jsonc" | head -5 | xargs -I {} sh -c 'echo "=== {} ===" && cat {}'

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 3257


🏁 Script executed:

# Check for React namespace usage patterns in the codebase
rg -n "React\.(ComponentProps|ElementRef|ComponentPropsWithoutRef|ReactNode)" --type=tsx --type=ts | head -20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 100


🏁 Script executed:

# Check if there are files using React.ComponentProps without importing React
rg -B 5 "React\.ComponentProps" --type=tsx --type=ts | head -40

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 100


🏁 Script executed:

# Find tenders-finder tsconfig
find tenders-finder -name "tsconfig*.json" | xargs cat

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1607


🏁 Script executed:

# Check for React namespace usage in tenders-finder project without type filter
rg "React\." tenders-finder/src --no-type-list | head -30

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 516


🏁 Script executed:

# Check if there's a global React type definition or next-env.d.ts
find tenders-finder -name "next-env.d.ts" -o -name "*.d.ts" | head -10 | xargs cat 2>/dev/null

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 110


🏁 Script executed:

# Search for React namespace usage in tenders-finder without type filtering
grep -r "React\." tenders-finder/src --include="*.tsx" --include="*.ts" | head -20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 2234


🏁 Script executed:

# Check if there's any React import anywhere in tenders-finder
grep -r "^import.*React" tenders-finder/src --include="*.tsx" --include="*.ts" | head -10

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 845


🏁 Script executed:

# Check package.json to see if React is actually a dependency
cat tenders-finder/package.json | grep -A 5 -B 5 "dependencies\|devDependencies" | head -40

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 751


Add a React import for React.ComponentProps usage.
React is referenced as a namespace but isn't imported. Every other component in the same directory correctly imports React, and this will cause a type-checking error.

🛠️ Proposed fix
+import * as React from "react";
 import { useTheme } from "next-themes";
 import { Toaster as Sonner, toast } from "sonner";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
import * as React from "react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
🤖 Prompt for AI Agents
In `@tenders-finder/src/components/ui/sonner.tsx` around lines 1 - 4, The file
uses React as a type namespace (React.ComponentProps) but does not import React,
causing type errors; add an import for React (e.g., import * as React from
"react") at the top of the module so the type alias ToasterProps =
React.ComponentProps<typeof Sonner> resolves correctly and other JSX/type usages
(ToasterProps, Sonner, toast) continue to work as expected.

Comment on lines +21 to +52
export async function startTenderSearch(
sector: Sector,
handlers: MinoEventHandler
): Promise<void> {
const response = await supabase.functions.invoke('mino-tender-search', {
body: { sector },
});

if (response.error) {
throw new Error(response.error.message);
}

// The edge function returns SSE-like data
// We'll process the response data
const data = response.data;

if (data && data.agents) {
for (const agent of data.agents) {
handlers.onAgentUpdate(agent.id, agent);

if (agent.tenders && agent.tenders.length > 0) {
for (const tender of agent.tenders) {
handlers.onTenderFound(tender);
}
}

if (agent.status === 'complete' || agent.status === 'error') {
handlers.onAgentComplete(agent.id);
}
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

API contract mismatch with serverless function.

The startTenderSearch function sends only { sector } to the serverless function, but the mino-tender-search endpoint (in supabase/functions/mino-tender-search/index.ts) validates that both sector and url are required (line 14: if (!sector || !url)), and expects agentId as well.

Additionally, the serverless function returns an SSE stream (text/event-stream), but this code expects response.data to be a JSON object with an agents array. Supabase's functions.invoke doesn't natively handle SSE streaming in this manner.

This will result in:

  1. A 400 error due to missing url parameter
  2. Incorrect parsing of the SSE response even if the validation passed
Suggested approach

Either:

  1. Refactor the client to call the function once per agent/source URL, passing { sector, url, agentId } for each source, or
  2. Modify the serverless function to accept just sector and iterate over sources internally, returning a non-streaming JSON response, or
  3. Use native fetch with proper SSE handling instead of supabase.functions.invoke
🤖 Prompt for AI Agents
In `@tenders-finder/src/lib/api/mino.ts` around lines 21 - 52, startTenderSearch
currently calls supabase.functions.invoke('mino-tender-search') with only {
sector } and assumes a JSON response, but the serverless function requires
sector, url (and agentId) and returns SSE; update startTenderSearch to either
(A) iterate your agent/source list and invoke the function per source passing {
sector, url, agentId } so the server validates and you handle each agent's
response, or (B) replace supabase.functions.invoke with a native
fetch/EventSource-based request to the function URL that sends { sector, url,
agentId } and parse the text/event-stream SSE responses, then call
handlers.onAgentUpdate, handlers.onTenderFound, and handlers.onAgentComplete as
events arrive; locate fixes around startTenderSearch, the
supabase.functions.invoke call, and the code paths that call
handlers.onAgentUpdate/onTenderFound/onAgentComplete.

Comment on lines +3 to +11
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};

serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

CORS Access-Control-Allow-Methods header required for preflight POST requests

💡 Result:

For a CORS preflight (an OPTIONS request) that precedes your actual POST, the browser sends Access-Control-Request-Method: POST to ask permission to use POST. (mdn2.netlify.app)

Your server’s preflight response must allow that method by returning Access-Control-Allow-Methods listing POST (or * when not using credentials). If the requested method isn’t allowed/listed, the browser blocks the request (e.g., “Did not find method in CORS header ‘Access-Control-Allow-Methods’”). (developer.mozilla.org)

Also, if the preflight includes Access-Control-Request-Headers: ..., your response must include Access-Control-Allow-Headers with those headers (otherwise the preflight fails). (developer.mozilla.org)

Example preflight response:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://your-frontend.example
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

Citations:


Add Access-Control-Allow-Methods in the CORS response.

The browser's CORS preflight checks for this header when handling cross-origin POST requests. Without it, browsers will reject the request with "Did not find method in CORS header 'Access-Control-Allow-Methods'". This will cause all cross-origin POST requests to fail.

🛠️ Proposed fix
 const corsHeaders = {
   'Access-Control-Allow-Origin': '*',
   'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+  'Access-Control-Allow-Methods': 'POST, OPTIONS',
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
};
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
🤖 Prompt for AI Agents
In `@tenders-finder/supabase/functions/discover-tender-links/index.ts` around
lines 3 - 11, The CORS preflight response in the serve handler is missing
Access-Control-Allow-Methods, causing browsers to reject cross-origin POSTs;
update the corsHeaders object (used in the OPTIONS branch where req.method ===
'OPTIONS') to include an 'Access-Control-Allow-Methods' entry listing the
allowed methods (e.g., 'GET, POST, OPTIONS') so the preflight response returns
the required methods header.

Comment on lines +35 to +61
// Use AI to discover relevant tender links
const response = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${LOVABLE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'google/gemini-3-flash-preview',
messages: [
{
role: 'system',
content: `You are an expert at finding government tender and procurement websites.
Return exactly 5 legitimate tender/procurement websites relevant to the given sector in Singapore.
Focus on official government portals, established tender aggregators, and industry-specific procurement sites.
Return as JSON array with 'name' and 'url' fields only.`
},
{
role: 'user',
content: `Find 5 tender/procurement websites for the "${sector}" sector in Singapore.
Include GeBIZ (the official Singapore government procurement portal) and 4 other relevant sites.
Return JSON only: [{"name": "Site Name", "url": "https://..."}]`
}
],
temperature: 0.3,
}),
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and examine the file
find . -name "index.ts" -path "*/discover-tender-links/*" 2>/dev/null

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 139


🌐 Web query:

Deno fetch AbortController timeout support edge functions

💡 Result:

Deno’s fetch() supports request timeouts via abort signals (not a dedicated timeout option):

  • Modern (preferred): AbortSignal.timeout(ms) — available in Deno v1.20+. It returns a signal that automatically aborts after the given milliseconds; fetch() rejects with a DOMException named "TimeoutError". (deno.com)

    try {
      const res = await fetch(url, { signal: AbortSignal.timeout(5_000) });
    } catch (e) {
      if (e instanceof DOMException && e.name === "TimeoutError") { /* timed out */ }
      else throw e;
    }
  • Fallback (works everywhere AbortController is supported): create an AbortController and call abort() from setTimeout, then pass controller.signal to fetch(). (sitepoint.com)

Edge Functions (Deno-based): If your edge runtime is Deno v1.20+ (many “Deno edge functions” platforms are), AbortSignal.timeout() should work the same way; otherwise use the fallback pattern. Supabase Edge Functions, for example, are explicitly Deno-based. (supabase.com)

Citations:


🌐 Web query:

Deno edge function timeout handling best practices

💡 Result:

Timeout-handling best practices for Deno-based edge functions

1) Design around platform limits (not just your code)

  • Know your wall-clock vs CPU limits. For example, Supabase Edge Functions have a wall-clock max duration (Free: 150s, Paid: 400s) and a per-request CPU-time limit (2s), plus an idle timeout that returns 504 if no response is sent in time. (supabase.com)
  • On Deno Deploy / Deno Subhosting, CPU time is limited (e.g., 50–200ms by tier) but waiting on I/O doesn’t count toward CPU time, so you can often wait on network calls—just don’t do heavy compute. (docs.deno.com)
  • Don’t assume an isolate will live forever: Deno Deploy may shut down an app after an idle period (between ~5s and 10min) once responses stop sending bytes. (docs.deno.com)

2) Put explicit timeouts on every outbound network call

Use AbortSignal.timeout() for fetch() so a slow upstream can’t consume your whole request budget. (deno.com)

const res = await fetch(url, {
  signal: AbortSignal.timeout(5_000),
});

Handle timeout separately (it rejects with TimeoutError). (deno.com)

3) Propagate cancellation from the client request

If the client disconnects, stop work:

  • Pass the incoming request’s abort signal (when available in your framework/runtime) into downstream operations (DB calls, fetches).
  • Also clear timers / intervals and stop streams when aborted (important for SSE/streaming patterns). (Deno Deploy keeps isolates alive as long as response bytes continue flowing.) (docs.deno.com)

4) Send headers early; stream long work when appropriate

Some edge platforms allow long work if you return a streaming response (headers within a deadline, then write bytes later). Netlify documents this pattern (return headers within ~40s; stream body to continue). (edge-functions-examples.netlify.app)

5) Avoid “long single request” architectures

If work might exceed your edge limits:

  • Return 202 Accepted quickly + enqueue work elsewhere (queue/job runner) + let clients poll/webhook.
  • Move periodic/cleanup tasks into the database/job scheduler when possible (e.g., DB cron), instead of edge requests that can time out. (This advice comes up frequently in Supabase edge discussions, and aligns with their hard runtime limits.) (supabase.com)

6) Budget time across steps (and fail fast)

  • Pick an overall request budget (e.g., 8s), then split it (DNS/connect/TLS, upstream fetch, parsing, DB).
  • Prefer short per-attempt timeouts + retries with backoff over one big timeout (and ensure idempotency).

7) Reduce CPU spikes (timeouts often show up as “CPU limit” failures)

  • Keep JSON (de)serialization, crypto, and large transforms minimal; cache where safe.
  • Don’t block the event loop; Deno Subhosting specifically warns against blocking the event loop for >1s. (docs.deno.com)

If you tell me which “Deno edge” you’re using (Deno Deploy, Supabase Edge Functions, Netlify Edge Functions, etc.) and your workload (SSE, webhook handler, cron/cleanup, proxying to slow APIs), I can map these into a concrete timeout budget + code structure.

Citations:


🏁 Script executed:

# Read the actual file to confirm code matches and check for any existing timeout
cat -n ./tenders-finder/supabase/functions/discover-tender-links/index.ts | head -80

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 3615


🏁 Script executed:

# Check for deno.json or supabase config to determine Deno version
fd -e json -e toml -path "*/deno.json*" -o -path "*/supabase.json*" -o -path "*/.denorc*" 2>/dev/null | head -10

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

# Look for any deno config or import_map files
find . -maxdepth 2 -name "deno.json*" -o -name "import_map.json" 2>/dev/null | head -5

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


Add an explicit timeout to the AI fetch request.
Without a timeout, the edge function can hang indefinitely if the AI gateway is slow or unresponsive, consuming the entire wall-clock budget (150s–400s on Supabase) and causing platform timeout failures. Explicit timeouts on all outbound network calls are a critical best practice for Deno edge functions.

🛠️ Proposed fix
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 10_000);
+    let response: Response;
+    try {
       const response = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', {
         method: 'POST',
         headers: {
           'Authorization': `Bearer ${LOVABLE_API_KEY}`,
           'Content-Type': 'application/json',
         },
         body: JSON.stringify({
           model: 'google/gemini-3-flash-preview',
           messages: [
             {
               role: 'system',
               content: `You are an expert at finding government tender and procurement websites. 
               Return exactly 5 legitimate tender/procurement websites relevant to the given sector in Singapore.
               Focus on official government portals, established tender aggregators, and industry-specific procurement sites.
               Return as JSON array with 'name' and 'url' fields only.`
             },
             {
               role: 'user',
               content: `Find 5 tender/procurement websites for the "${sector}" sector in Singapore. 
               Include GeBIZ (the official Singapore government procurement portal) and 4 other relevant sites.
               Return JSON only: [{"name": "Site Name", "url": "https://..."}]`
             }
           ],
           temperature: 0.3,
         }),
+        signal: controller.signal,
       });
+    } finally {
+      clearTimeout(timeoutId);
+    }

Alternatively, use the modern AbortSignal.timeout(10_000) pattern if Deno v1.20+ is confirmed, which is cleaner and handles cleanup automatically.

🤖 Prompt for AI Agents
In `@tenders-finder/supabase/functions/discover-tender-links/index.ts` around
lines 35 - 61, Add an explicit timeout to the AI fetch call that requests the
Gemini model: wrap the fetch to
'https://ai.gateway.lovable.dev/v1/chat/completions' (the variable response)
with an AbortController (or use AbortSignal.timeout(10_000) if your Deno runtime
supports it) and pass the controller.signal in the fetch options; ensure the
timeout triggers controller.abort() (or relies on AbortSignal.timeout) after
~10_000ms and handle the abort/timeout error path so the function fails fast
instead of hanging.

Comment on lines +77 to +96
const response = await fetch('https://mino.ai/v1/automation/run-sse', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
},
body: JSON.stringify({ url, goal }),
});

if (!response.ok) {
const errorText = await response.text();
console.error(`[${agentId}] Mino API error:`, response.status, errorText);
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'ERROR',
agentId,
error: `Mino API error: ${response.status}`
})}\n\n`));
controller.close();
return;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing timeout for external API call.

The fetch call to https://mino.ai/v1/automation/run-sse has no timeout. If the Mino API hangs or is slow to respond, this serverless function will block indefinitely until the platform times it out, potentially consuming resources unnecessarily.

Suggested fix using AbortController
+          const controller = new AbortController();
+          const timeoutId = setTimeout(() => controller.abort(), 120000); // 2 minute timeout
+
           const response = await fetch('https://mino.ai/v1/automation/run-sse', {
             method: 'POST',
             headers: {
               'Content-Type': 'application/json',
               'X-API-Key': apiKey,
             },
             body: JSON.stringify({ url, goal }),
+            signal: controller.signal,
           });
+
+          clearTimeout(timeoutId);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await fetch('https://mino.ai/v1/automation/run-sse', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
},
body: JSON.stringify({ url, goal }),
});
if (!response.ok) {
const errorText = await response.text();
console.error(`[${agentId}] Mino API error:`, response.status, errorText);
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'ERROR',
agentId,
error: `Mino API error: ${response.status}`
})}\n\n`));
controller.close();
return;
}
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), 120000); // 2 minute timeout
const response = await fetch('https://mino.ai/v1/automation/run-sse', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
},
body: JSON.stringify({ url, goal }),
signal: abortController.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
console.error(`[${agentId}] Mino API error:`, response.status, errorText);
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'ERROR',
agentId,
error: `Mino API error: ${response.status}`
})}\n\n`));
controller.close();
return;
}
🤖 Prompt for AI Agents
In `@tenders-finder/supabase/functions/mino-tender-search/index.ts` around lines
77 - 96, The fetch to 'https://mino.ai/v1/automation/run-sse' lacks a timeout;
wrap the request in an AbortController with a configurable timeout (e.g.,
10–30s), pass controller.signal to fetch, and clear the timeout once a response
is received; on timeout/AbortError, log including agentId and send an ERROR SSE
via controller.enqueue (using encoder) similar to the existing error path, then
controller.close() and return. Ensure you reference the same variables used in
the diff (apiKey, url, goal, agentId, controller, encoder) and only abort the
specific fetch call so other logic continues to work.

Comment on lines +8 to +14
server: {
host: "::",
port: 8080,
hmr: {
overlay: false,
},
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Vite dev server host configuration security defaults "::" IPv6

💡 Result:

  • Vite’s secure default is to bind the dev server to localhost (server.host default: 'localhost'), i.e. reachable only from the local machine. (vite.dev)
  • Setting server.host to true or 0.0.0.0 makes Vite listen on all interfaces (LAN/public reachable). (vite.dev)
  • Setting server.host to "::" binds Node/Vite to the unspecified IPv6 address (all IPv6 interfaces). On many OSes this also accepts IPv4 (dual-stack), effectively similar exposure to 0.0.0.0 from a security standpoint. (nodejs.org)

Host-header / DNS-rebinding protections (important when you expose the server):

  • Vite restricts which Host: headers it will answer via server.allowedHosts (default []). By default it allows localhost, *.localhost, and all IP addresses; setting it to true is explicitly dangerous (DNS rebinding). (vite.dev)
  • Vite’s dev-server CORS default only allows origins matching localhost/127.0.0.1/[::1]; setting server.cors: true is also explicitly dangerous. (vite.dev)

Practical takeaway: using --host :: (or host: '::') is not a “safe default”—it deliberately exposes the dev server beyond your machine. If you need LAN access, prefer an explicit allowlist (server.allowedHosts) and keep server.cors restrictive. (vite.dev)

Citations:


🌐 Web query:

IPv6 "::" binding all interfaces security development server

💡 Result:

Binding a development server to IPv6 :: means “listen on all IPv6 interfaces” (the IPv6 wildcard, like IPv4 0.0.0.0). That is not loopback-only, so it can expose your dev server to other machines on your LAN (and potentially the internet) if routing/firewall rules allow it. [1]

Key security/behavior points

  • :: = all IPv6 interfaces (wildcard). Anyone who can reach your machine over IPv6 may reach the service. This is the same class of risk as binding to 0.0.0.0 on IPv4. [1]
  • On many Linux systems, binding to :: may also accept IPv4 connections via IPv4-mapped IPv6 addresses (e.g., ::ffff:192.0.2.1) because IPV6_V6ONLY is often off by default (dual-stack socket). So you might accidentally expose the service over both IPv6 and IPv4. [2][3]
  • OS behavior differs. Whether :: also accepts IPv4 depends on IPV6_V6ONLY defaults / configuration; some systems default to v6-only while Linux often defaults to dual-stack. [2]

Safer defaults for a dev server

  • If you want local-only, bind to:
    • IPv6 loopback: ::1
    • IPv4 loopback: 127.0.0.1
  • If you must bind to :: for testing on other devices:
    • Ensure your host firewall restricts access (e.g., only your LAN / only specific IPs).
    • Consider forcing IPv6-only behavior (disable v4-mapped acceptance) using IPV6_V6ONLY / the runtime’s equivalent option (many stacks expose this explicitly). [2]

Sources: [1] (binding to all interfaces as a security risk), [2] (Linux dual-stack when binding :: with IPV6_V6ONLY default off), [3] (IPv4-mapped IPv6 explanation / why IPv4 shows under inet6).


🌐 Web query:

Vite localhost vs "::" host configuration best practices

💡 Result:

What localhost vs :: means in Vite

  • server.host: 'localhost' (default) binds Vite to the loopback interface only (meant for “this machine only”). Vite explicitly documents the default as 'localhost'. [1]

    • Gotcha: depending on Node/DNS behavior, localhost may resolve to IPv4 (127.0.0.1) or IPv6 (::1), and Vite calls out cases where the resolved address can differ from what it’s listening on. [1]
  • server.host: '::' binds to the IPv6 unspecified address (“all IPv6 interfaces”), analogous to binding IPv4 to 0.0.0.0. The IPv6 :: address is the unspecified / wildcard address used to listen on all interfaces. [5]

    • Practical gotcha: on some OS/network stacks, binding to :: may also accept IPv4 via dual-stack behavior unless configured otherwise (this is a platform/socket option concern, not Vite-specific).
  • server.host: '0.0.0.0' or server.host: true binds to all interfaces (LAN/public reachable), which Vite explicitly recommends when you need access from other devices/containers. [1]

Best-practice guidance

  1. Default to localhost for local-only dev

    • Use server.host: 'localhost' (or omit it) when you don’t need other devices to connect. This minimizes accidental exposure. [1]
  2. If you hit IPv4/IPv6 “localhost” weirdness, be explicit

    • If your browser resolves localhost to ::1 but the server is listening on IPv4 (or vice versa), set an explicit host:
      • server.host: '127.0.0.1' (force IPv4 loopback), or
      • server.host: '::1' (force IPv6 loopback).
    • Vite also notes a Node/DNS ordering issue around localhost and suggests adjusting Node’s DNS result order in some cases. [1]
  3. Use 0.0.0.0 / true for LAN, Docker, remote-device testing

    • This is the most common “I need to open it on my phone / another computer / inside containers” setting. Vite explicitly says 0.0.0.0 or true listens on all addresses (including LAN/public). [1]
  4. Only use :: when you specifically want IPv6 wildcard binding

    • It’s reasonable in IPv6-first environments, but it can be confusing because:
      • clients must connect via IPv6 (unless your platform gives dual-stack behavior),
      • URLs may show IPv6 formatting, and
      • firewall/routing behavior differs from IPv4.
    • If your goal is simply “reachable from other devices,” 0.0.0.0/true is usually simpler and matches Vite’s documented guidance. [1]
  5. Don’t “solve host issues” by disabling host checks

    • If you use custom local domains, prefer server.allowedHosts entries rather than true.
    • Vite warns that setting allowedHosts: true is not recommended because it’s vulnerable to DNS rebinding attacks. [1]
    • If you control the hostname → IP mapping (e.g., /etc/hosts), adding those hosts explicitly is considered safe guidance by Vite maintainers. [2]

Quick decision table

Goal Recommended server.host
Local-only dev localhost (default) [1]
Fix localhost IPv4/IPv6 mismatch 127.0.0.1 or ::1 (explicit loopback); consider Node DNS ordering note [1]
Test from phone/LAN/Docker 0.0.0.0 or true [1]
IPv6-only / IPv6-first lab :: (know your dual-stack + firewall behavior) [5]

Sources: Vite server options (host + allowedHosts + DNS note) [1], Vite maintainer guidance on allowedHosts and DNS rebinding context [2], definition/meaning of IPv6 :: unspecified address [5].


Binding the dev server to all IPv6 interfaces defeats Vite's secure default.

Line 9 sets host: "::", which binds to all IPv6 interfaces (the IPv6 wildcard, equivalent to 0.0.0.0). On dual-stack systems, this also accepts IPv4 connections. Vite's default is 'localhost' (loopback only); this configuration explicitly exposes the dev server to the LAN and potentially beyond. Use localhost unless intentionally enabling remote access.

🔒 Safer default with opt-in override
 export default defineConfig(({ mode }) => ({
   server: {
-    host: "::",
+    host: process.env.VITE_DEV_HOST ?? "localhost",
     port: 8080,
     hmr: {
       overlay: false,
     },
   },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
server: {
host: "::",
port: 8080,
hmr: {
overlay: false,
},
},
server: {
host: process.env.VITE_DEV_HOST ?? "localhost",
port: 8080,
hmr: {
overlay: false,
},
},
🤖 Prompt for AI Agents
In `@tenders-finder/vite.config.ts` around lines 8 - 14, The dev server is
currently bound to all interfaces via server.host set to "::", which exposes the
Vite dev server beyond localhost; update the configuration in vite.config.ts by
changing server.host to 'localhost' (or make it configurable via an opt-in ENV
like VITE_HOST) so the default remains loopback-only; locate the server block
(server: { host: "::", port: 8080, hmr: { overlay: false } }) and replace the
host value or add logic to read an environment flag to only bind to all
interfaces when explicitly requested.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🤖 Fix all issues with AI agents
In `@tutors-finder/.env`:
- Around line 1-3: Remove the committed .env file from the repository (delete
the file from the repo and stop tracking it), add .env to .gitignore, and
replace the committed file with a .env.example containing placeholder values for
VITE_SUPABASE_PROJECT_ID, VITE_SUPABASE_PUBLISHABLE_KEY, and VITE_SUPABASE_URL;
also update the project README with setup steps for creating a local .env from
.env.example and instruct the team to immediately rotate the exposed Supabase
keys since the publishable key and project ID were committed.

In `@tutors-finder/src/components/ExamSelector.tsx`:
- Line 19: The exams array uses React.ElementType without importing React types;
add a type-only import from React (e.g., import type { ElementType } from
'react') and update the exams declaration to use ElementType instead of
React.ElementType (locate the exams constant in ExamSelector.tsx to make the
change).

In `@tutors-finder/src/components/LocationInput.tsx`:
- Around line 1-21: The file references React.FormEvent in the handleSubmit
function of the LocationInput component but doesn't import React; add a
type-only import for React (e.g., import type React from 'react') at the top of
the file so the type React.FormEvent resolves and compilation succeeds; update
the import list above LocationInput to include this type import without
affecting the JSX runtime.

In `@tutors-finder/src/components/TutorCard.tsx`:
- Around line 23-33: The TutorCard clickable div isn't keyboard-accessible;
update the TutorCard component (the div using onToggleSelect and isSelected) to
behave like a button by adding role="button", tabIndex={0},
aria-pressed={isSelected}, and an onKeyDown handler that invokes onToggleSelect
when Enter or Space is pressed (handle Space by preventing default to avoid page
scroll). Keep the existing onClick and visual styles intact so both mouse and
keyboard users can toggle selection.

In `@tutors-finder/src/components/ui/chart.tsx`:
- Around line 61-88: The generated CSS in ChartStyle is vulnerable and can
produce invalid selectors or CSS injection because id, config keys and color
values are interpolated directly into dangerouslySetInnerHTML; fix ChartStyle by
escaping the id and keys with CSS.escape(id) and CSS.escape(key) when building
selectors and custom property names, validate/whitelist color values (accept
only hex, rgb(a), hsl(a) or CSS variables like --var) and skip any invalid
colors, and stop using dangerouslySetInnerHTML by building a safe string and
passing it as the style element's child (or use createElement with text content)
so the final CSS uses quoted/escaped attribute selectors ([data-chart="..."])
and only safe variable assignments; reference ChartStyle, colorConfig, THEMES
and the mapping that produces `--color-${key}: ${color}` to locate and update
the code.

In `@tutors-finder/src/components/ui/progress.tsx`:
- Around line 9-18: The Progress component is destructuring away the value prop
and not passing it to ProgressPrimitive.Root, which breaks aria attributes and
Radix state; update the component so the received value (the prop named value)
is forwarded into ProgressPrimitive.Root (alongside existing {...props}) so Root
can set aria-valuenow and track progress while leaving
ProgressPrimitive.Indicator's transform logic unchanged.

In `@tutors-finder/src/components/ui/skeleton.tsx`:
- Around line 1-4: The component currently uses the global React namespace for
types (React.HTMLAttributes) which isn't available per tsconfig; change to an
explicit type-only import and use it in the Skeleton signature: add an import
type { HTMLAttributes } from "react" and update the props typing to
HTMLAttributes<HTMLDivElement> in the Skeleton function (keep the existing
destructuring of className and ...props and the cn call intact).

In `@tutors-finder/src/components/ui/sonner.tsx`:
- Around line 1-7: The file uses React.ComponentProps to define ToasterProps but
never imports React; add a React import (preferably a type-only import like
`import type * as React from "react"` to match the codebase pattern) so the
React namespace is defined, then keep the existing ToasterProps and Toaster
components (symbols: ToasterProps, Toaster, React.ComponentProps, useTheme)
unchanged.

In `@tutors-finder/src/hooks/use-toast.ts`:
- Around line 169-177: The useEffect in use-toast is re-subscribing on every
state change because `state` is included in the dependency array; remove `state`
from the dependency array and use an empty array so the effect subscribes once,
relying on the stable `setState` reference to push into `listeners` and for
cleanup to splice the same `setState` out; update the effect signature to
React.useEffect(() => { listeners.push(setState); return () => { const index =
listeners.indexOf(setState); if (index > -1) listeners.splice(index, 1); }; },
[]) so subscriptions are only added/removed once.

In `@tutors-finder/src/hooks/useTutorSearch.ts`:
- Around line 98-205: The agent stream handlers reuse agent IDs and perform
setState even after a new search starts, causing stale updates; add a search
token (e.g., currentSearchId via useRef inside useTutorSearch) that is
incremented/updated when a new search begins, capture that token in each
agentPromises async closure, and before every setState (and before processing
chunks) verify the captured token === currentSearchId.current and skip
processing if not, ensuring agentPromises, agentId and setState only update
state for the active search.

In `@tutors-finder/src/integrations/supabase/client.ts`:
- Around line 5-17: The module currently calls createClient with SUPABASE_URL
and SUPABASE_PUBLISHABLE_KEY directly; add a guard that validates SUPABASE_URL
and SUPABASE_PUBLISHABLE_KEY are present before invoking createClient and, if
missing, throw a clear Error mentioning the missing VITE_SUPABASE_URL and/or
VITE_SUPABASE_PUBLISHABLE_KEY so initialization fails fast; update the top of
this module that defines SUPABASE_URL/SUPABASE_PUBLISHABLE_KEY and perform the
check prior to creating/exporting the supabase client.

In `@tutors-finder/supabase/functions/discover-tutor-websites/index.ts`:
- Around line 54-67: The fetch to
'https://ai.gateway.lovable.dev/v1/chat/completions' currently has no timeout;
modify the call in index.ts to use an AbortController and a short timeout (e.g.,
5–15s): create an AbortController before calling fetch, pass controller.signal
in the fetch options where the Authorization/Content-Type headers and body are
set, start a setTimeout that calls controller.abort() after the chosen timeout,
and clear the timeout when the response arrives; handle the abort error (from
the fetch call) appropriately in the same function so the function returns a
failure response instead of hanging.

In `@tutors-finder/supabase/functions/search-tutors-mino/index.ts`:
- Around line 101-109: The SSE forwarding currently drops blank lines (which
delimit events) because of the if (line.trim()) check; update the loop in the
stream handling that processes buffer/lines (the code that calls decoder.decode,
splits into lines, assigns buffer, and iterates lines) to forward every line
unchanged—call writer.write(encoder.encode(line + '\n')) for each line
(including empty ones) instead of skipping blank lines so event boundaries are
preserved.
🟡 Minor comments (19)
tutors-finder/src/components/ui/carousel.tsx-96-102 (1)

96-102: ⚠️ Potential issue | 🟡 Minor

Remove the reInit listener in cleanup.

The reInit listener is registered (line 97) but not removed in the cleanup function. Embla Carousel listeners persist across reInit events, so without explicit cleanup, duplicate handlers accumulate when the effect re-runs—which occurs whenever api or onSelect changes.

Suggested fix
      return () => {
        api?.off("select", onSelect);
+       api?.off("reInit", onSelect);
      };
tutors-finder/src/components/ui/input-otp.tsx-52-57 (1)

52-57: ⚠️ Potential issue | 🟡 Minor

Hide the decorative dot icon from assistive tech.
The separator dot is decorative and should be removed from the accessibility tree. Although lucide-react icons have aria-hidden="true" by default, explicitly marking it as inert is a best practice for clarity and compatibility.

♿ Suggested accessibility tweak
-  ({ ...props }, ref) => (
-    <div ref={ref} role="separator" {...props}>
-      <Dot />
+  ({ ...props }, ref) => (
+    <div ref={ref} role="separator" {...props}>
+      <Dot aria-hidden="true" focusable="false" />
     </div>
   ),
tutors-finder/src/components/ui/input-otp.tsx-24-48 (1)

24-48: ⚠️ Potential issue | 🟡 Minor

Add defensive guard for context and slot access to prevent runtime crashes.

Line 29 directly destructures from inputOTPContext.slots[index] without checking if the context or slot exists. If this component is used outside an InputOTP provider or with an invalid index, it will throw. The input-otp v1.4.x library does not guarantee that slots[index] is always present.

Suggested fix
  const inputOTPContext = React.useContext(OTPInputContext);
+  const slot = inputOTPContext?.slots?.[index];
+  if (!slot) {
+    return null;
+  }
-  const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
+  const { char, hasFakeCaret, isActive } = slot;
tutors-finder/src/components/ui/chart.tsx-92-226 (1)

92-226: ⚠️ Potential issue | 🟡 Minor

Don’t hide zero values in tooltip; tighten key stability.

item.value && ... drops legitimate 0 values. Also key={item.dataKey} can be undefined, leading to unstable keys.

✅ Suggested fix
-          {payload.map((item, index) => {
+          {payload.map((item, index) => {
             const key = `${nameKey || item.name || item.dataKey || "value"}`;
             const itemConfig = getPayloadConfigFromPayload(config, item, key);
             const indicatorColor = color || item.payload.fill || item.color;
+            const itemKey = `${item.dataKey ?? item.name ?? index}`;
 
             return (
               <div
-                key={item.dataKey}
+                key={itemKey}
                 className={cn(
                   "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
                   indicator === "dot" && "items-center",
                 )}
               >
@@
-                      {item.value && (
+                      {item.value !== undefined && item.value !== null && (
                         <span className="font-mono font-medium tabular-nums text-foreground">
                           {item.value.toLocaleString()}
                         </span>
                       )}
tutors-finder/src/components/ExamSelector.tsx-38-40 (1)

38-40: ⚠️ Potential issue | 🟡 Minor

Add type="button" to prevent accidental form submission.
When rendered inside a form, the default type is submit.

🔧 Proposed fix
-          <button
+          <button type="button"
             key={type}
             onClick={() => onSelect(type)}
tutors-finder/src/components/CompareDashboard.tsx-59-61 (1)

59-61: ⚠️ Potential issue | 🟡 Minor

Add accessible label to close button.

The icon-only button lacks an accessible label for screen reader users. Consider adding aria-label or using sr-only text.

♿ Proposed fix for accessibility
-          <Button variant="ghost" size="icon" onClick={onClose}>
+          <Button variant="ghost" size="icon" onClick={onClose} aria-label="Close comparison">
             <X className="w-6 h-6" />
           </Button>
tutors-finder/src/hooks/use-toast.ts-6-6 (1)

6-6: ⚠️ Potential issue | 🟡 Minor

TOAST_REMOVE_DELAY of ~16.7 minutes seems excessively long.

TOAST_REMOVE_DELAY = 1000000 (1,000,000ms ≈ 16.7 minutes) means dismissed toasts remain in state for a very long time before being removed. This is likely unintentional. Typical values are 3-5 seconds.

🐛 Proposed fix
 const TOAST_LIMIT = 1;
-const TOAST_REMOVE_DELAY = 1000000;
+const TOAST_REMOVE_DELAY = 5000; // 5 seconds
tutors-finder/supabase/functions/discover-tutor-websites/index.ts-94-114 (1)

94-114: ⚠️ Potential issue | 🟡 Minor

Fallback only triggers on JSON parse errors, not on empty/invalid AI responses.

The fallback logic at lines 105-113 only executes when JSON.parse throws. If the AI returns valid JSON that doesn't match the expected schema (e.g., an empty array, or objects missing name/url fields), websites will be set to that invalid data without validation.

🛡️ Proposed fix to validate parsed websites
     let websites: { name: string; url: string }[] = [];
     try {
       // Try to extract JSON array from the response
       const jsonMatch = content.match(/\[[\s\S]*\]/);
       if (jsonMatch) {
-        websites = JSON.parse(jsonMatch[0]);
+        const parsed = JSON.parse(jsonMatch[0]);
+        // Validate that parsed data is an array with valid entries
+        if (Array.isArray(parsed) && parsed.length > 0 && 
+            parsed.every(w => typeof w.name === 'string' && typeof w.url === 'string')) {
+          websites = parsed;
+        } else {
+          throw new Error('Invalid website data structure');
+        }
       }
     } catch (parseError) {
tutors-finder/src/components/ui/breadcrumb.tsx-69-80 (1)

69-80: ⚠️ Potential issue | 🟡 Minor

Fix BreadcrumbEllipsis displayName typo.

This affects debug tooling and devtools readability.

🛠️ Proposed fix
-BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
+BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";
tutors-finder/src/components/ui/breadcrumb.tsx-7-13 (1)

7-13: ⚠️ Potential issue | 🟡 Minor

separator prop is unused in Breadcrumb.

Either wire this prop through a context/child API or remove it to avoid a misleading public surface.

🛠️ Proposed fix (remove unused prop)
 const Breadcrumb = React.forwardRef<
   HTMLElement,
-  React.ComponentPropsWithoutRef<"nav"> & {
-    separator?: React.ReactNode;
-  }
+  React.ComponentPropsWithoutRef<"nav">
 >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
tutors-finder/src/components/ui/card.tsx-17-22 (1)

17-22: ⚠️ Potential issue | 🟡 Minor

Align CardTitle ref type with the rendered heading element.

The component renders an <h3> but the ref is typed as HTMLParagraphElement. Change the ref type to HTMLHeadingElement to match the actual DOM element.

🛠️ Proposed fix
-const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
+const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
   ({ className, ...props }, ref) => (
     <h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
   ),
 );
tutors-finder/src/components/ui/alert.tsx-36-43 (1)

36-43: ⚠️ Potential issue | 🟡 Minor

Fix AlertDescription ref and props types to match the rendered <div>.

The component renders a <div> but is typed as HTMLParagraphElement for both the ref and props.

🛠️ Proposed fix
-const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
+const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
   ({ className, ...props }, ref) => (
     <div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
   ),
 );
tutors-finder/src/components/ui/alert.tsx-29-34 (1)

29-34: ⚠️ Potential issue | 🟡 Minor

Fix AlertTitle ref type to match <h5>.

The ref is typed as HTMLParagraphElement but the component renders an <h5>, which should use HTMLHeadingElement.

Proposed fix
-const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
+const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
   ({ className, ...props }, ref) => (
     <h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
   ),
 );
tutors-finder/src/components/ui/form.tsx-33-54 (1)

33-54: ⚠️ Potential issue | 🟡 Minor

Guard useFormField before calling getFieldState.

The current if (!fieldContext) check is ineffective—context defaults to {} (truthy). Additionally, getFieldState is invoked with fieldContext.name (line 38) before validating it exists, and itemContext.id is destructured (line 44) without null checks. Validate both fieldContext.name and itemContext.id before calling getFieldState.

🛠️ Proposed fix
 const useFormField = () => {
   const fieldContext = React.useContext(FormFieldContext);
   const itemContext = React.useContext(FormItemContext);
   const { getFieldState, formState } = useFormContext();
-
-  const fieldState = getFieldState(fieldContext.name, formState);
-
-  if (!fieldContext) {
+  if (!fieldContext?.name) {
     throw new Error("useFormField should be used within <FormField>");
   }
+  if (!itemContext?.id) {
+    throw new Error("useFormField should be used within <FormItem>");
+  }
+
+  const fieldState = getFieldState(fieldContext.name, formState);
 
   const { id } = itemContext;
tutors-finder/src/hooks/useTutorSearch.ts-45-53 (1)

45-53: ⚠️ Potential issue | 🟡 Minor

Keep state.exam in sync when starting a search

startSearch resets state but doesn’t persist the exam argument, so state.exam can stay stale if callers don’t invoke setExam first.

🛠️ Proposed fix
-    setState((prev) => ({
-      ...prev,
-      location,
+    setState((prev) => ({
+      ...prev,
+      exam,
+      location,
       isSearching: true,
       isDiscovering: true,
       agents: [],
       tutors: [],
       selectedTutorIds: new Set(),
     }));
tutors-finder/src/components/ui/menubar.tsx-188-188 (1)

188-188: ⚠️ Potential issue | 🟡 Minor

Fix typo: displayname should be displayName.

The property name is misspelled with a lowercase n. This will result in the display name not being properly set for React DevTools and error messages.

🐛 Proposed fix
-MenubarShortcut.displayname = "MenubarShortcut";
+MenubarShortcut.displayName = "MenubarShortcut";
tutors-finder/src/components/ui/sidebar.tsx-54-69 (1)

54-69: ⚠️ Potential issue | 🟡 Minor

Persisted state is written but never read.
The cookie is set on toggle but never used to initialize state, so the sidebar won’t restore after refresh. Consider hydrating _open from the cookie.

🧩 Proposed fix
-  const [_open, _setOpen] = React.useState(defaultOpen);
+  const [_open, _setOpen] = React.useState(() => {
+    if (typeof document === "undefined") return defaultOpen;
+    const cookie = document.cookie
+      .split("; ")
+      .find((row) => row.startsWith(`${SIDEBAR_COOKIE_NAME}=`))
+      ?.split("=")[1];
+    if (cookie === "true") return true;
+    if (cookie === "false") return false;
+    return defaultOpen;
+  });
tutors-finder/src/pages/Index.tsx-19-43 (1)

19-43: ⚠️ Potential issue | 🟡 Minor

Reset compare state when starting or resetting a search.

showCompare stays true after a reset, so a new search can auto-open the compare dashboard once two tutors are selected. Clear it when starting or resetting the search.

🛠️ Suggested fix
-  const handleSearch = (location: string) => {
-    if (state.exam) {
-      startSearch(state.exam, location);
-    }
-  };
+  const handleSearch = (location: string) => {
+    if (state.exam) {
+      setShowCompare(false);
+      startSearch(state.exam, location);
+    }
+  };
+
+  const handleReset = () => {
+    setShowCompare(false);
+    resetSearch();
+  };
@@
-            <Button variant="ghost" size="sm" onClick={resetSearch} className="gap-2">
+            <Button variant="ghost" size="sm" onClick={handleReset} className="gap-2">
tutors-finder/README.md-42-113 (1)

42-113: ⚠️ Potential issue | 🟡 Minor

Align README terminology and code fence with the Tutor Finder stack.

The snippet is JavaScript but fenced as bash, and the tech stack/diagram still reference “Next.js” and “Tender” terminology. Please update to reflect the Tutors Finder Vite/React app and tutor-centric architecture.

✏️ Suggested doc fixes
-```bash
+```ts
@@
-**Next.js (TypeScript)**
+**Vite + React + TypeScript**
@@
-ORCH["Tender Search Orchestration Layer<br/>(Next.js API / Server Actions)"]
+ORCH["Tutor Search Orchestration Layer<br/>(Serverless API / Functions)"]
@@
-DB["SUPABASE<br/>(Cached Tenders & Metadata)"]
+DB["SUPABASE<br/>(Cached Tutors & Metadata)"]
@@
-DBD["• Cached tender listings<br/>• Deduplicated tenders<br/>• Historical records"]
-MINOD["• Parallel web agents<br/>• Browse govt tender portals<br/>• Open tender pages<br/>• Extract structured fields<br/>• SSE streaming updates"]
+DBD["• Cached tutor listings<br/>• Deduplicated tutors<br/>• Historical records"]
+MINOD["• Parallel web agents<br/>• Browse tutor platforms<br/>• Open tutor pages<br/>• Extract structured fields<br/>• SSE streaming updates"]
🧹 Nitpick comments (10)
tutors-finder/src/components/CompareButton.tsx (2)

29-31: Minor: Button text has trailing space when no items selected.

When selectedCount is 0, the button renders "Compare " with a trailing space before the empty string. Consider trimming or restructuring:

✨ Suggested fix
-        Compare {selectedCount > 0 ? `(${selectedCount} selected)` : ''}
+        Compare{selectedCount > 0 ? ` (${selectedCount} selected)` : ''}

23-34: Consider hiding the button when nothing is selected.

The compare button is always visible even when selectedCount is 0. While this aids discoverability, it may add visual clutter. Consider conditionally rendering or disabling the button when there are no selections.

✨ Option: Conditionally render the button
+  if (selectedCount === 0) {
+    return null;
+  }
+
   return (
     <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
tutors-finder/src/hooks/use-mobile.tsx (1)

9-14: Consider using event.matches for consistency with the MediaQueryList.

The onChange handler ignores the MediaQueryListEvent and re-queries window.innerWidth. Using event.matches is more idiomatic and avoids the redundant width check.

♻️ Suggested refactor
   React.useEffect(() => {
     const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
-    const onChange = () => {
-      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
-    };
+    const onChange = (event: MediaQueryListEvent) => {
+      setIsMobile(event.matches);
+    };
     mql.addEventListener("change", onChange);
-    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+    setIsMobile(mql.matches);
     return () => mql.removeEventListener("change", onChange);
   }, []);
tutors-finder/src/components/ui/progress.tsx (1)

16-17: Clamp value to [0, 100] to avoid over/under-fill.

If value goes below 0 or above 100, the bar can render outside bounds. A small clamp improves robustness without affecting typical use.

♻️ Suggested tweak
-    <ProgressPrimitive.Indicator
+    <ProgressPrimitive.Indicator
       className="h-full w-full flex-1 bg-primary transition-all"
-      style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
+      style={{
+        transform: `translateX(-${100 - Math.min(100, Math.max(0, value ?? 0))}%)`,
+      }}
     />
tutors-finder/src/components/ui/chart.tsx (1)

230-275: Legend keys/labels should be stable and non-empty.

key={item.value} can collide and labels can render empty if config omits label. Consider a stable key fallback and a label fallback to item.value or item.name.

♻️ Suggested refinement
-      {payload.map((item) => {
+      {payload.map((item) => {
         const key = `${nameKey || item.dataKey || "value"}`;
         const itemConfig = getPayloadConfigFromPayload(config, item, key);
+        const itemKey = `${item.dataKey ?? item.value ?? item.name ?? ""}`;

         return (
           <div
-            key={item.value}
+            key={itemKey}
             className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
           >
@@
-            {itemConfig?.label}
+            {itemConfig?.label ?? item.value ?? item.name}
           </div>
         );
       })}
tutors-finder/src/components/ui/toggle-group.tsx (1)

34-37: Variant/size priority may be inverted.

The current logic context.variant || variant gives context priority over item-level props. This means if ToggleGroup sets a variant, individual ToggleGroupItem cannot override it. Typically, item-level props should take precedence to allow per-item customization.

💡 Proposed fix to prioritize item-level props
         toggleVariants({
-          variant: context.variant || variant,
-          size: context.size || size,
+          variant: variant || context.variant,
+          size: size || context.size,
         }),
tutors-finder/supabase/functions/discover-tutor-websites/index.ts (1)

92-92: Avoid logging full AI response content in production.

Logging the entire AI response at line 92 could expose sensitive or verbose data in production logs. Consider logging only essential metadata or using a debug flag.

♻️ Suggested change
-    console.log('AI response:', content);
+    console.log('AI response received, length:', content.length);
tutors-finder/src/components/TutorResultsGrid.tsx (1)

57-85: Consider extracting placeholder card to a separate component.

The placeholder card markup (lines 61-82) is fairly verbose. If similar placeholders are used elsewhere or this component grows, extracting it to a TutorCardPlaceholder component would improve maintainability.

tutors-finder/src/components/ui/command.tsx (1)

24-24: Consider simplifying the empty interface extension.

The CommandDialogProps interface extends DialogProps without adding any properties. You could use a type alias instead for clarity.

♻️ Suggested simplification
-interface CommandDialogProps extends DialogProps {}
+type CommandDialogProps = DialogProps;
tutors-finder/src/pages/NotFound.tsx (1)

1-18: Use Link for internal SPA navigation instead of an anchor tag.

React Router's Link handles client-side routing and avoids full page reloads, while <a href> triggers a hard refresh. Since "/" is an internal route, using Link follows React Router best practices.

Suggested refactor
-import { useLocation } from "react-router-dom";
+import { Link, useLocation } from "react-router-dom";
@@
-        <a href="/" className="text-primary underline hover:text-primary/90">
+        <Link to="/" className="text-primary underline hover:text-primary/90">
           Return to Home
-        </a>
+        </Link>

Comment on lines +1 to +3
VITE_SUPABASE_PROJECT_ID="hspjxcouamcdqrvqhkgg"
VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhzcGp4Y291YW1jZHFydnFoa2dnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk2NzI4NzEsImV4cCI6MjA4NTI0ODg3MX0.1A4KloHAiPP6ObODTZnXicntDqMfwDbQP-_FoW4eyUY"
VITE_SUPABASE_URL="https://hspjxcouamcdqrvqhkgg.supabase.co"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

CRITICAL: Secrets committed to version control.

This .env file contains sensitive Supabase credentials (publishable key and project ID) that should never be committed to version control. The static analysis tool correctly flagged a JWT token on line 2. Committing secrets creates serious security risks:

  • Unauthorized access to your Supabase project
  • Potential data breaches or abuse
  • Credentials are now in Git history even if removed later

Immediate action required:

  1. Remove this file from the repository and add .env to .gitignore
  2. Rotate the Supabase keys immediately since they've been exposed
  3. Create a .env.example file with placeholder values instead:
    VITE_SUPABASE_PROJECT_ID="your-project-id"
    VITE_SUPABASE_PUBLISHABLE_KEY="your-publishable-key"
    VITE_SUPABASE_URL="your-supabase-url"
    
  4. Document in README how developers should set up their local .env file

Note on static analysis warnings:
The dotenv-linter warnings about quote characters are false positives and can be safely ignored - Vite environment variables commonly use quotes in .env files.

🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 1-1: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 2-2: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 3-3: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)

🪛 Gitleaks (8.30.0)

[high] 2-2: Uncovered a JSON Web Token, which may lead to unauthorized access to web applications and sensitive user data.

(jwt)

🤖 Prompt for AI Agents
In `@tutors-finder/.env` around lines 1 - 3, Remove the committed .env file from
the repository (delete the file from the repo and stop tracking it), add .env to
.gitignore, and replace the committed file with a .env.example containing
placeholder values for VITE_SUPABASE_PROJECT_ID, VITE_SUPABASE_PUBLISHABLE_KEY,
and VITE_SUPABASE_URL; also update the project README with setup steps for
creating a local .env from .env.example and instruct the team to immediately
rotate the exposed Supabase keys since the publishable key and project ID were
committed.

onSelect: (exam: ExamType) => void;
}

const exams: { type: ExamType; label: string; icon: React.ElementType; description: string }[] = [
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, find and examine the ExamSelector.tsx file
fd ExamSelector.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 118


🏁 Script executed:

#!/bin/bash
# Check the imports and content of ExamSelector.tsx
cat -n tutors-finder/src/components/ExamSelector.tsx | head -30

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1484


🏁 Script executed:

#!/bin/bash
# Check TypeScript configuration
cat tutors-finder/tsconfig.json

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 441


🏁 Script executed:

#!/bin/bash
# Look for jsx and types configuration in tsconfig
rg -n 'jsx|types|skipLibCheck|skipDefaultLibCheck' tutors-finder/tsconfig.json

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 101


🏁 Script executed:

#!/bin/bash
# Check tsconfig.app.json for JSX configuration
cat tutors-finder/tsconfig.app.json

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 757


🏁 Script executed:

#!/bin/bash
# Search for any global React type declarations
fd -e d.ts | xargs grep -l "declare.*React" 2>/dev/null || echo "No global React declarations found"

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 107


Fix missing React type import for React.ElementType.
Line 19 uses React.ElementType without importing React, which will fail type-checking. Add a type-only import and use ElementType directly.

🛠️ Proposed fix
-import { 
+import { 
   GraduationCap, 
   BookOpen, 
   Award, 
   Target, 
   Globe, 
   Microscope, 
   Trophy,
   Calculator
 } from 'lucide-react';
+import type { ElementType } from 'react';
 import { cn } from '@/lib/utils';
 import type { ExamType } from '@/types/tutor';
@@
-const exams: { type: ExamType; label: string; icon: React.ElementType; description: string }[] = [
+const exams: { type: ExamType; label: string; icon: ElementType; description: string }[] = [
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const exams: { type: ExamType; label: string; icon: React.ElementType; description: string }[] = [
import {
GraduationCap,
BookOpen,
Award,
Target,
Globe,
Microscope,
Trophy,
Calculator
} from 'lucide-react';
import type { ElementType } from 'react';
import { cn } from '@/lib/utils';
import type { ExamType } from '@/types/tutor';
const exams: { type: ExamType; label: string; icon: ElementType; description: string }[] = [
🤖 Prompt for AI Agents
In `@tutors-finder/src/components/ExamSelector.tsx` at line 19, The exams array
uses React.ElementType without importing React types; add a type-only import
from React (e.g., import type { ElementType } from 'react') and update the exams
declaration to use ElementType instead of React.ElementType (locate the exams
constant in ExamSelector.tsx to make the change).

Comment on lines +1 to +21
import { useState } from 'react';
import { MapPin, Search, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import type { ExamType } from '@/types/tutor';

interface LocationInputProps {
exam: ExamType;
onSearch: (location: string) => void;
isLoading: boolean;
}

export function LocationInput({ exam, onSearch, isLoading }: LocationInputProps) {
const [location, setLocation] = useState('');

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (location.trim()) {
onSearch(location.trim());
}
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and inspect TypeScript configuration
find . -name "tsconfig.json" -o -name "tsconfig.*.json" | head -20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 516


🏁 Script executed:

# Find the LocationInput component file
find . -name "LocationInput.tsx" -type f

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 121


🏁 Script executed:

# Check if React is imported elsewhere or if there's a global type definition
rg "React\.FormEvent" --type tsx --type ts -A 2 -B 2

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 100


🏁 Script executed:

# Read the TypeScript config for tutors-finder
cat tutors-finder/tsconfig.json

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 441


🏁 Script executed:

# Read the LocationInput component
cat -n tutors-finder/src/components/LocationInput.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 2518


🏁 Script executed:

# Search for React.FormEvent in the repository
rg "React\.FormEvent" -A 2 -B 2

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 2071


🏁 Script executed:

# Check the app-specific tsconfig to see JSX runtime settings
cat tutors-finder/tsconfig.app.json

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 757


🏁 Script executed:

# Check if React is globally available through type definitions or declare
rg "declare global" --type-add "ts:*.ts" --type-add "tsx:*.tsx" -A 5 tutors-finder/

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

# Check vite or build config for JSX settings
find tutors-finder -name "vite.config.*" -o -name "next.config.*" | xargs cat 2>/dev/null

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 566


Fix missing React type import (compile error).

Line 16 references React.FormEvent, but React isn't imported. With jsx: "react-jsx" in tsconfig, the React namespace must be explicitly imported to use type utilities like FormEvent.

✅ Proposed fix
-import { useState } from 'react';
+import { useState, type FormEvent } from 'react';
@@
-  const handleSubmit = (e: React.FormEvent) => {
+  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
     e.preventDefault();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { useState } from 'react';
import { MapPin, Search, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import type { ExamType } from '@/types/tutor';
interface LocationInputProps {
exam: ExamType;
onSearch: (location: string) => void;
isLoading: boolean;
}
export function LocationInput({ exam, onSearch, isLoading }: LocationInputProps) {
const [location, setLocation] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (location.trim()) {
onSearch(location.trim());
}
};
import { useState, type FormEvent } from 'react';
import { MapPin, Search, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import type { ExamType } from '@/types/tutor';
interface LocationInputProps {
exam: ExamType;
onSearch: (location: string) => void;
isLoading: boolean;
}
export function LocationInput({ exam, onSearch, isLoading }: LocationInputProps) {
const [location, setLocation] = useState('');
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (location.trim()) {
onSearch(location.trim());
}
};
🤖 Prompt for AI Agents
In `@tutors-finder/src/components/LocationInput.tsx` around lines 1 - 21, The file
references React.FormEvent in the handleSubmit function of the LocationInput
component but doesn't import React; add a type-only import for React (e.g.,
import type React from 'react') at the top of the file so the type
React.FormEvent resolves and compilation succeeds; update the import list above
LocationInput to include this type import without affecting the JSX runtime.

Comment on lines +23 to +33
export function TutorCard({ tutor, isSelected, onToggleSelect }: TutorCardProps) {
return (
<div
onClick={onToggleSelect}
className={cn(
'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card',
'hover:shadow-lg hover:border-primary/30',
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border'
)}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "TutorCard.tsx" | head -5

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 117


🏁 Script executed:

cat -n ./tutors-finder/src/components/TutorCard.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 5888


🏁 Script executed:

# Verify if there are any event handlers or accessibility attributes we might have missed
rg -A 10 "onToggleSelect" ./tutors-finder/src/components/TutorCard.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 565


Make the card keyboard-accessible.
The clickable <div> on line 25 lacks keyboard support, blocking keyboard users and screen reader users from selecting tutors. The element has onClick but no onKeyDown handler, no role attribute, and no tabIndex, making it unfocusable and non-semantic.

♿ Proposed fix
-    <div
-      onClick={onToggleSelect}
+    <div
+      onClick={onToggleSelect}
+      onKeyDown={(e) => {
+        if (e.key === 'Enter' || e.key === ' ') {
+          e.preventDefault();
+          onToggleSelect();
+        }
+      }}
+      role="button"
+      tabIndex={0}
+      aria-pressed={isSelected}
       className={cn(
         'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card',
         'hover:shadow-lg hover:border-primary/30',
         isSelected
           ? 'border-primary bg-primary/5 shadow-md'
           : 'border-border'
       )}
     >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function TutorCard({ tutor, isSelected, onToggleSelect }: TutorCardProps) {
return (
<div
onClick={onToggleSelect}
className={cn(
'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card',
'hover:shadow-lg hover:border-primary/30',
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border'
)}
export function TutorCard({ tutor, isSelected, onToggleSelect }: TutorCardProps) {
return (
<div
onClick={onToggleSelect}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggleSelect();
}
}}
role="button"
tabIndex={0}
aria-pressed={isSelected}
className={cn(
'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card',
'hover:shadow-lg hover:border-primary/30',
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border'
)}
🤖 Prompt for AI Agents
In `@tutors-finder/src/components/TutorCard.tsx` around lines 23 - 33, The
TutorCard clickable div isn't keyboard-accessible; update the TutorCard
component (the div using onToggleSelect and isSelected) to behave like a button
by adding role="button", tabIndex={0}, aria-pressed={isSelected}, and an
onKeyDown handler that invokes onToggleSelect when Enter or Space is pressed
(handle Space by preventing default to avoid page scroll). Keep the existing
onClick and visual styles intact so both mouse and keyboard users can toggle
selection.

Comment on lines +61 to +88
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);

if (!colorConfig.length) {
return null;
}

return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find the chart.tsx file and understand its context
find . -name "chart.tsx" -type f | head -20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 254


🏁 Script executed:

# Look for how ChartStyle component is imported and used
rg "ChartStyle" --type tsx --type ts -A 2 -B 2

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 100


🏁 Script executed:

# Search for where the `id` prop is passed to ChartStyle
rg "data-chart" --type tsx --type ts -B 5 -A 2

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 100


🏁 Script executed:

# Check the broader context of the chart.tsx file to understand exports and structure
head -100 tutors-finder/src/components/ui/chart.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 3360


🏁 Script executed:

# Search for how ChartContainer is used across the repo
rg "ChartContainer" -A 3 -B 1

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 4933


🏁 Script executed:

# Look for chart imports and usage patterns
rg "from.*chart" --type ts --type tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 100


🏁 Script executed:

# Check if id/config come from API or user data
rg "chart" -i tutors-finder/src -A 2 | head -80

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 5854


🏁 Script executed:

# Find actual usage of ChartContainer in tutors-finder
find tutors-finder -name "*.tsx" -o -name "*.ts" | xargs grep -l "ChartContainer" 2>/dev/null

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 114


🏁 Script executed:

# Look at specific usage examples
rg "ChartContainer" tutors-finder -A 5 -B 2 --max-count=5

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1829


🏁 Script executed:

# Check if configs are from user data or hardcoded
rg "config.*=" tutors-finder/src -A 2 | grep -i "chart\|config" | head -30

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 2107


🏁 Script executed:

# Search for actual usage of ChartContainer across all projects
rg "ChartContainer" --type ts --type tsx --max-count=50 | grep -v "\.tsx-"

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 100


🏁 Script executed:

# Check for components that import ChartContainer
rg "import.*ChartContainer|from.*chart" tutors-finder/src -A 2 -B 1

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 354


🏁 Script executed:

# Look for page or component files that use charts
find tutors-finder/src -name "*.tsx" | xargs grep -l "chart\|Chart" 2>/dev/null | head -10

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 114


🏁 Script executed:

# Search more broadly for chart usage
rg "ChartContainer\|BarChart\|LineChart" tutors-finder/src --max-count=20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

# Find all tsx files and grep for Chart components
find tutors-finder/src -type f -name "*.tsx" -exec basename {} \;

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 922


🏁 Script executed:

# Look for import statements of chart components
find tutors-finder/src -type f -name "*.tsx" -exec grep -l "from.*chart\|ChartContainer\|LineChart\|BarChart" {} \;

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 114


🏁 Script executed:

# Check CompareDashboard.tsx which likely uses charts
cat tutors-finder/src/components/CompareDashboard.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 5475


🏁 Script executed:

# Check App.tsx for chart usage
grep -n "chart\|Chart" tutors-finder/src/components/App.tsx | head -20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 142


🏁 Script executed:

# Look at the full list of exports from chart.tsx to understand the API
grep -n "export" tutors-finder/src/components/ui/chart.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 213


🏁 Script executed:

# Search across ALL projects for actual ChartContainer usage
find . -name "*.tsx" -o -name "*.ts" | xargs grep -l "ChartContainer" 2>/dev/null | grep -v "chart.tsx"

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

# Search for any imports from the chart.tsx file across all projects
rg "from.*ui/chart" . --max-count=20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

# Check the actual vulnerability: look at the CSS selector syntax issue
cat -n tutors-finder/src/components/ui/chart.tsx | sed -n '61,88p'

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 959


🏁 Script executed:

# More thorough search - look for any component files that use charts
find tutors-finder/src -type f \( -name "*.tsx" -o -name "*.ts" \) -exec grep -l "Chart" {} \;

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 114


🏁 Script executed:

# Look in other projects for ChartContainer usage
find . -type f \( -name "*.tsx" -o -name "*.ts" \) -exec grep -l "ChartContainer\|LineChart\|BarChart" {} \; | grep -v node_modules | head -20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 355


🏁 Script executed:

# Check if the chart components are actually exported and used
git log --oneline tutors-finder/src/components/ui/chart.tsx | head -5

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 98


🏁 Script executed:

# Check competitor-analysis usage
grep -n "ChartContainer\|LineChart\|BarChart" ./competitor-analysis/app/competitors/page.tsx -B 2 -A 2

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 302


🏁 Script executed:

# Check dashboard-layout usage
grep -n "ChartContainer\|LineChart\|BarChart" ./competitor-analysis/components/dashboard-layout.tsx -B 2 -A 2

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 508


🏁 Script executed:

# Look at how config is constructed in a real example
cat ./competitor-analysis/components/dashboard-layout.tsx | head -150

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 5234


🏁 Script executed:

# Comprehensive search for actual ChartContainer usage across all projects
find . -type f -name "*.tsx" -exec grep -l "ChartContainer" {} \; 2>/dev/null | grep -v "chart.tsx$"

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

# Check if the component is used via default exports or re-exports
rg "ChartContainer|BarChart|LineChart" . --type ts --type tsx --max-count=50 2>/dev/null | grep -v "node_modules" | grep -v "\.map" | grep -v "chart\.tsx"

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

# Verify the actual CSS selector syntax issue - test if unquoted selector breaks
cat > /tmp/test_css.html << 'EOF'
<!DOCTYPE html>
<html>
<head>
<style>
/* Valid syntax - quoted */
.valid [data-chart="chart-123"] { color: red; }

/* Invalid syntax - unquoted with spaces (will fail) */
.invalid [data-chart=chart 123 with spaces] { color: blue; }
</style>
</head>
<body>
<div class="valid" data-chart="chart-123">Valid selector (quoted)</div>
<div class="invalid" data-chart="chart 123 with spaces">Invalid selector (unquoted with spaces)</div>
</body>
</html>
EOF
cat /tmp/test_css.html

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 518


🏁 Script executed:

# Check if CSS.escape is a standard API
web_search "CSS.escape browser support MDN"

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 123


🌐 Web query:

CSS.escape JavaScript standard browser support documentation

💡 Result:

Authoritative documentation (standard + API docs)

  • MDN (Web API reference): CSS.escape() — syntax, examples, and a browser-compatibility section. [1]
  • Specification: CSS Object Model (CSSOM) — “The CSS.escape() Method” (defines partial interface CSS { static DOMString escape(DOMString ident); }). [2]

Browser support (quick reference)

  • Can I use: CSS.escape() — support tables and global usage (shows broad modern support; notably not supported in Internet Explorer, and older Edge versions). [3]

Polyfill (if you need legacy support)

  • Mathias Bynens’ css.escape polyfill (npm) — widely used polyfill implementing the CSSOM-defined behavior. [4]

Sources

[1] MDN — CSS: escape() static method: CSS.escape() https://developer.mozilla.org/en-US/docs/Web/API/CSS/escape_static
[2] W3C TR — CSSOM, §8.1 CSS.escape() https://www.w3.org/TR/2013/WD-cssom-20131205/
[3] Can I use — CSS.escape() https://caniuse.com/wf-css-escape
[4] npm — css.escape polyfill https://www.npmjs.com/package/css.escape


🌐 Web query:

CSS attribute selector syntax unquoted values specification

💡 Result:

In CSS attribute selectors, the value part can be written unquoted only when it parses as a CSS identifier token; otherwise it must be a string (quoted).

  • Selectors Level 3 (REC): “Attribute values must be CSS identifiers or strings.” This is why both are valid: a[hreflang=fr] (identifier) and span[class="example"] (string). [1]
  • Selectors Level 4 states the same rule in token terms: “Attribute values must be <ident-token>s or <string-token>s.” [2]
  • What counts as an identifier is defined by CSS Syntax: an ident sequence uses ident-start code points (letter, non-ASCII, or _) and then ident code points (ident-start, digit, or -), and any code point can be included via CSS escapes. [3]
  • MDN summarizes the practical implication: attribute selector <value> is an <ident> or <string>, and must be quoted if it contains spaces or special characters. [4]

Practical rule of thumb

  • OK unquoted (identifier):
    input[type=checkbox], a[hreflang=en-US], div[data-kind=foo]
  • Must be quoted (not an identifier, or you don’t want to rely on escapes):
    a[href="https://example.com"] (contains : and /), *[title="hello world"] (space), div[data-x="a=b"] (=)

Sources:
[1] Selectors Level 3 (W3C REC 2018) [attribute values must be identifiers or strings]. (w3.org)
[2] Selectors Level 4 (CSSWG draft) [attribute values must be <ident-token> or <string-token>]. (w3c.github.io)
[3] CSS Syntax Module Level 3 (W3C) [ident-start/ident code points; escaping]. (w3.org)
[4] MDN Attribute selectors [value is <ident> or <string>; quote when spaces/special chars]. (developer.mozilla.org)

Citations:


🏁 Script executed:

# Final check: see if ChartConfig is populated from user data or hardcoded
rg "ChartConfig\|config.*=" competitor-analysis -A 3 -B 1 | head -50

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

# Look for API calls that might populate chart config
rg "fetch\|axios\|api" competitor-analysis -A 2 | grep -i "config\|chart" | head -20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


Fix CSS selector generation to prevent injection and ensure valid CSS syntax.

The unquoted attribute selector [data-chart=${id}] breaks CSS parsing if id contains spaces or special characters (CSS attribute selectors require quotes for non-identifier values per CSS Selectors spec). Additionally, config keys and color values are interpolated without escaping or validation, enabling CSS/variable injection if derived from untrusted sources. Using dangerouslySetInnerHTML with string concatenation amplifies these risks.

Escape the id and config keys using CSS.escape(), validate colors against common formats, and render the style content directly without dangerouslySetInnerHTML:

Proposed fix
+const escapeCssIdent = (value: string) =>
+  typeof CSS !== "undefined" && CSS.escape ? CSS.escape(value) : value.replace(/[^a-zA-Z0-9_-]/g, "_");
+
+const isSafeColor = (value?: string) =>
+  !!value && /^(#([0-9a-fA-F]{3,8})|rgb(a)?\(|hsl(a)?\(|var\(--)/.test(value);
+
 const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
   const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);

   if (!colorConfig.length) {
     return null;
   }

-  return (
-    <style
-      dangerouslySetInnerHTML={{
-        __html: Object.entries(THEMES)
-          .map(
-            ([theme, prefix]) => `
-${prefix} [data-chart=${id}] {
-${colorConfig
-  .map(([key, itemConfig]) => {
-    const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
-    return color ? `  --color-${key}: ${color};` : null;
-  })
-  .join("\n")}
-}
-`,
-          )
-          .join("\n"),
-      }}
-    />
-  );
+  const safeId = escapeCssIdent(id);
+  const css = Object.entries(THEMES)
+    .map(
+      ([theme, prefix]) => `
+${prefix} [data-chart="${safeId}"] {
+${colorConfig
+  .map(([key, itemConfig]) => {
+    const rawColor = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
+    const color = isSafeColor(rawColor) ? rawColor : undefined;
+    const safeKey = escapeCssIdent(key);
+    return color ? `  --color-${safeKey}: ${color};` : null;
+  })
+  .join("\n")}
+}
+`,
+    )
+    .join("\n");
+
+  return <style>{css}</style>;
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const escapeCssIdent = (value: string) =>
typeof CSS !== "undefined" && CSS.escape ? CSS.escape(value) : value.replace(/[^a-zA-Z0-9_-]/g, "_");
const isSafeColor = (value?: string) =>
!!value && /^(#([0-9a-fA-F]{3,8})|rgb(a)?\(|hsl(a)?\(|var\(--)/.test(value);
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
const safeId = escapeCssIdent(id);
const css = Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart="${safeId}"] {
${colorConfig
.map(([key, itemConfig]) => {
const rawColor = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
const color = isSafeColor(rawColor) ? rawColor : undefined;
const safeKey = escapeCssIdent(key);
return color ? ` --color-${safeKey}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n");
return <style>{css}</style>;
};
🧰 Tools
🪛 ast-grep (0.40.5)

[warning] 69-69: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🤖 Prompt for AI Agents
In `@tutors-finder/src/components/ui/chart.tsx` around lines 61 - 88, The
generated CSS in ChartStyle is vulnerable and can produce invalid selectors or
CSS injection because id, config keys and color values are interpolated directly
into dangerouslySetInnerHTML; fix ChartStyle by escaping the id and keys with
CSS.escape(id) and CSS.escape(key) when building selectors and custom property
names, validate/whitelist color values (accept only hex, rgb(a), hsl(a) or CSS
variables like --var) and skip any invalid colors, and stop using
dangerouslySetInnerHTML by building a safe string and passing it as the style
element's child (or use createElement with text content) so the final CSS uses
quoted/escaped attribute selectors ([data-chart="..."]) and only safe variable
assignments; reference ChartStyle, colorConfig, THEMES and the mapping that
produces `--color-${key}: ${color}` to locate and update the code.

Comment on lines +169 to +177
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Incorrect dependency in useEffect causes unnecessary re-subscriptions.

The useEffect dependency array includes state, but state changes on every dispatch, causing the effect to re-run and re-subscribe on every toast update. This leads to unnecessary listener removal and re-addition. The effect should have an empty dependency array since setState is stable.

🐛 Proposed fix
   React.useEffect(() => {
     listeners.push(setState);
     return () => {
       const index = listeners.indexOf(setState);
       if (index > -1) {
         listeners.splice(index, 1);
       }
     };
-  }, [state]);
+  }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, []);
🤖 Prompt for AI Agents
In `@tutors-finder/src/hooks/use-toast.ts` around lines 169 - 177, The useEffect
in use-toast is re-subscribing on every state change because `state` is included
in the dependency array; remove `state` from the dependency array and use an
empty array so the effect subscribes once, relying on the stable `setState`
reference to push into `listeners` and for cleanup to splice the same `setState`
out; update the effect signature to React.useEffect(() => {
listeners.push(setState); return () => { const index =
listeners.indexOf(setState); if (index > -1) listeners.splice(index, 1); }; },
[]) so subscriptions are only added/removed once.

Comment on lines +98 to +205
const agentPromises = websites.map(async (site, index) => {
const agentId = `agent-${index}`;

try {
const response = await fetch(
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/search-tutors-mino`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY}`,
},
body: JSON.stringify({
websiteUrl: site.url,
websiteName: site.name,
exam,
}),
}
);

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

const reader = response.body?.getReader();
if (!reader) throw new Error('No reader');

const decoder = new TextDecoder();
let buffer = '';

while (true) {
const { done, value } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';

for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const jsonStr = line.slice(6).trim();
if (jsonStr === '[DONE]') continue;

try {
const data = JSON.parse(jsonStr);

// Update agent status
setState((prev) => ({
...prev,
agents: prev.agents.map((a) =>
a.id === agentId
? {
...a,
streamingUrl: data.streamingUrl || a.streamingUrl,
status: data.type === 'COMPLETE' ? 'complete' : 'searching',
message: data.message || a.message,
}
: a
),
}));

// Add tutors when complete
if (data.type === 'COMPLETE' && data.resultJson?.tutors) {
const newTutors: Tutor[] = data.resultJson.tutors.map(
(t: any, i: number) => ({
id: `${agentId}-tutor-${i}`,
tutorName: t.tutorName || 'Unknown',
examsTaught: t.examsTaught || [],
subjects: t.subjects || [],
teachingMode: t.teachingMode || null,
location: t.location || null,
experience: t.experience || null,
qualifications: t.qualifications || null,
pricing: t.pricing || null,
pastResults: t.pastResults || null,
contactMethod: t.contactMethod || null,
profileLink: t.profileLink || null,
sourceWebsite: t.sourceWebsite || site.name,
})
);

setState((prev) => ({
...prev,
tutors: [...prev.tutors, ...newTutors],
agents: prev.agents.map((a) =>
a.id === agentId ? { ...a, tutors: newTutors, status: 'complete' } : a
),
}));
}
} catch (e) {
// Ignore parse errors for incomplete JSON
}
}
}
} catch (error) {
console.error(`Agent ${agentId} error:`, error);
setState((prev) => ({
...prev,
agents: prev.agents.map((a) =>
a.id === agentId
? { ...a, status: 'error', message: 'Search failed' }
: a
),
}));
}
});

await Promise.allSettled(agentPromises);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's read the full useTutorSearch.ts file to understand the complete context
fd -n "useTutorSearch.ts" tutors-finder/src

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 303


🏁 Script executed:

#!/bin/bash
# Find and read the hook file
find . -name "useTutorSearch.ts" -type f

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 116


🏁 Script executed:

#!/bin/bash
# Search for the useTutorSearch hook file
find tutors-finder/src -name "*.ts" -o -name "*.tsx" | grep -i tutor

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 3246


🏁 Script executed:

#!/bin/bash
# Read the full useTutorSearch.ts file
wc -l tutors-finder/src/hooks/useTutorSearch.ts

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 118


🏁 Script executed:

#!/bin/bash
# Get full content with line numbers
cat -n tutors-finder/src/hooks/useTutorSearch.ts

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 8545


🏁 Script executed:

#!/bin/bash
# Search for startSearch usage across the codebase
rg "startSearch" tutors-finder/src -n -B 2 -A 2

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1414


🏁 Script executed:

#!/bin/bash
# Check for isSearching usage in components
rg "isSearching" tutors-finder/src -n -B 2 -A 2

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 6369


🏁 Script executed:

#!/bin/bash
# Check LocationInput to see if it has disabled state when isSearching
cat -n tutors-finder/src/components/LocationInput.tsx | head -50

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 2123


🏁 Script executed:

#!/bin/bash
# Check how LocationInput is called in Index.tsx
sed -n '65,75p' tutors-finder/src/pages/Index.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 425


🏁 Script executed:

#!/bin/bash
# Double-check if startSearch is called anywhere else or if there's a race condition
rg "startSearch\|isSearching" tutors-finder/src/pages/Index.tsx -n

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

#!/bin/bash
# Verify no AbortController or cancellation mechanism exists
rg "AbortController|AbortSignal|cancel" tutors-finder/src/hooks/useTutorSearch.ts

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


Guard against stale async updates from overlapping searches

Agent IDs (agent-${index}) are reused across searches, and there's no cancellation or token-based guard. If a new search starts before prior SSE streams finish, in-flight state updates from old promises will still execute and mutate state by matching the reused agent IDs. This can mix tutor results from different searches.

Track an active search ID and skip setState calls from stale searches. The suggested approach using useRef and a searchId check before each setState call would resolve this:

🛠️ Possible fix with a search token guard
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useRef } from 'react';

 export function useTutorSearch() {
   const [state, setState] = useState<SearchState>({
     exam: null,
     location: '',
     isSearching: false,
     isDiscovering: false,
     agents: [],
     tutors: [],
     selectedTutorIds: new Set(),
   });
+  const activeSearchId = useRef(0);

   const startSearch = useCallback(async (exam: ExamType, location: string) => {
+    const searchId = ++activeSearchId.current;
-                setState((prev) => ({
+                if (activeSearchId.current !== searchId) return;
+                setState((prev) => ({
                   ...prev,
                   agents: prev.agents.map((a) =>
-          setState((prev) => ({
+          if (activeSearchId.current !== searchId) return;
+          setState((prev) => ({
             ...prev,
             agents: prev.agents.map((a) =>
-      await Promise.allSettled(agentPromises);
-      setState((prev) => ({ ...prev, isSearching: false }));
+      await Promise.allSettled(agentPromises);
+      if (activeSearchId.current === searchId) {
+        setState((prev) => ({ ...prev, isSearching: false }));
+      }
🤖 Prompt for AI Agents
In `@tutors-finder/src/hooks/useTutorSearch.ts` around lines 98 - 205, The agent
stream handlers reuse agent IDs and perform setState even after a new search
starts, causing stale updates; add a search token (e.g., currentSearchId via
useRef inside useTutorSearch) that is incremented/updated when a new search
begins, capture that token in each agentPromises async closure, and before every
setState (and before processing chunks) verify the captured token ===
currentSearchId.current and skip processing if not, ensuring agentPromises,
agentId and setState only update state for the active search.

Comment on lines +5 to +17
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;

// Import the supabase client like this:
// import { supabase } from "@/integrations/supabase/client";

export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
auth: {
storage: localStorage,
persistSession: true,
autoRefreshToken: true,
}
}); No newline at end of file
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n tutors-finder/src/integrations/supabase/client.ts

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 782


🌐 Web query:

Supabase JavaScript createClient undefined URL anon key behavior error handling

💡 Result:

What happens if supabaseUrl / supabaseAnonKey are undefined?

createClient(supabaseUrl, supabaseKey) requires both parameters. If either value is missing/undefined, Supabase JS throws immediately (synchronously) with:

  • Error: supabaseUrl is required.
  • Error: supabaseKey is required.  [1]

Supabase documents both parameters as required. [2]

Why this usually happens

Almost always, your environment variables aren’t actually being injected into the runtime that executes createClient, so you end up passing undefined (common with Vite/Next.js/client-side builds). A frequent Vite-specific cause is forgetting the VITE_ prefix, so the values never reach import.meta.env. [3]

Recommended error handling pattern

Validate at module init (or in a factory) and throw a clear error before calling createClient:

import { createClient } from "@supabase/supabase-js";

const url = import.meta.env.VITE_SUPABASE_URL;      // or process.env.NEXT_PUBLIC_SUPABASE_URL
const key = import.meta.env.VITE_SUPABASE_ANON_KEY; // or process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY

if (!url || !key) {
  throw new Error("Missing Supabase env vars: SUPABASE_URL / SUPABASE_ANON_KEY");
}

export const supabase = createClient(url, key);

One important “anon key” note (Next.js / frontend)

Only variables prefixed for the client (e.g. NEXT_PUBLIC_ in Next.js, VITE_ in Vite) will be available in browser code—otherwise they’ll be undefined and trigger the errors above. [3]

Sources: GitHub discussion showing the exact thrown errors [1]; Supabase docs for required params [2]; example root-cause/fix via env var injection + Vite prefix [3].

[1] (github.com)
[2] (supabase.com)
[3] (dev.to)

Citations:


Add validation for missing Supabase environment variables before initialization.

createClient throws synchronously if VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY is undefined, causing runtime errors that can be difficult to debug. Since this file is auto-generated, apply the validation in the generation source or wrap this module with validation logic:

const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;

if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
  throw new Error("Missing required Supabase environment variables: VITE_SUPABASE_URL and/or VITE_SUPABASE_PUBLISHABLE_KEY");
}

This ensures fast failure with a clear error message during module initialization rather than at runtime when the client is actually used.

🤖 Prompt for AI Agents
In `@tutors-finder/src/integrations/supabase/client.ts` around lines 5 - 17, The
module currently calls createClient with SUPABASE_URL and
SUPABASE_PUBLISHABLE_KEY directly; add a guard that validates SUPABASE_URL and
SUPABASE_PUBLISHABLE_KEY are present before invoking createClient and, if
missing, throw a clear Error mentioning the missing VITE_SUPABASE_URL and/or
VITE_SUPABASE_PUBLISHABLE_KEY so initialization fails fast; update the top of
this module that defines SUPABASE_URL/SUPABASE_PUBLISHABLE_KEY and perform the
check prior to creating/exporting the supabase client.

Comment on lines +54 to +67
const response = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${LOVABLE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'google/gemini-3-flash-preview',
messages: [
{ role: 'user', content: prompt }
],
temperature: 0.7,
}),
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a timeout to the external API call to prevent hanging requests.

The fetch call to the AI gateway has no timeout configured. If the external service is slow or unresponsive, this function will hang indefinitely until Deno's default timeout kicks in, which could be very long.

🛡️ Proposed fix using AbortController
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
+
     const response = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', {
       method: 'POST',
       headers: {
         'Authorization': `Bearer ${LOVABLE_API_KEY}`,
         'Content-Type': 'application/json',
       },
       body: JSON.stringify({
         model: 'google/gemini-3-flash-preview',
         messages: [
           { role: 'user', content: prompt }
         ],
         temperature: 0.7,
       }),
+      signal: controller.signal,
     });
+
+    clearTimeout(timeoutId);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${LOVABLE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'google/gemini-3-flash-preview',
messages: [
{ role: 'user', content: prompt }
],
temperature: 0.7,
}),
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
const response = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${LOVABLE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'google/gemini-3-flash-preview',
messages: [
{ role: 'user', content: prompt }
],
temperature: 0.7,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
🤖 Prompt for AI Agents
In `@tutors-finder/supabase/functions/discover-tutor-websites/index.ts` around
lines 54 - 67, The fetch to 'https://ai.gateway.lovable.dev/v1/chat/completions'
currently has no timeout; modify the call in index.ts to use an AbortController
and a short timeout (e.g., 5–15s): create an AbortController before calling
fetch, pass controller.signal in the fetch options where the
Authorization/Content-Type headers and body are set, start a setTimeout that
calls controller.abort() after the chosen timeout, and clear the timeout when
the response arrives; handle the abort error (from the fetch call) appropriately
in the same function so the function returns a failure response instead of
hanging.

Comment on lines +101 to +109
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';

for (const line of lines) {
if (line.trim()) {
// Forward the SSE event to the client
await writer.write(encoder.encode(line + '\n'));
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve SSE event boundaries when forwarding the stream.

Blank lines delimit SSE events; the if (line.trim()) filter drops them, which can prevent clients from dispatching events correctly. Forward all lines (including empty ones) or stream raw chunks unchanged.

✅ Minimal fix to keep event delimiters
-          for (const line of lines) {
-            if (line.trim()) {
-              // Forward the SSE event to the client
-              await writer.write(encoder.encode(line + '\n'));
-            }
-          }
+          for (const line of lines) {
+            // Forward the SSE event to the client (including blank lines)
+            await writer.write(encoder.encode(line + '\n'));
+          }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
// Forward the SSE event to the client
await writer.write(encoder.encode(line + '\n'));
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
// Forward the SSE event to the client (including blank lines)
await writer.write(encoder.encode(line + '\n'));
}
🤖 Prompt for AI Agents
In `@tutors-finder/supabase/functions/search-tutors-mino/index.ts` around lines
101 - 109, The SSE forwarding currently drops blank lines (which delimit events)
because of the if (line.trim()) check; update the loop in the stream handling
that processes buffer/lines (the code that calls decoder.decode, splits into
lines, assigns buffer, and iterates lines) to forward every line unchanged—call
writer.write(encoder.encode(line + '\n')) for each line (including empty ones)
instead of skipping blank lines so event boundaries are preserved.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🤖 Fix all issues with AI agents
In `@tutor-finder/src/components/TutorCard.tsx`:
- Around line 25-33: The div used as the clickable card in the TutorCard
component should be made keyboard- and screen-reader-accessible: add
role="button", tabIndex={0}, aria-pressed={isSelected}, and an onKeyDown handler
that listens for Enter and Space and calls onToggleSelect (for Space call
preventDefault to stop page scroll) so keyboard users can toggle selection;
update the element where onToggleSelect, isSelected and cn are used to include
these attributes and the handler.

In `@tutor-finder/src/components/ui/sidebar.tsx`:
- Around line 138-170: The mobile rendering currently spreads ...props onto
Sheet and doesn't forward ref or merge className/style into SheetContent; update
the mobile branch so SheetContent (not Sheet) receives the forwarded ref and the
component props (merge incoming className with the existing class string and
merge the style object to include "--sidebar-width": SIDEBAR_WIDTH_MOBILE while
preserving any provided styles), and remove spreading ...props onto Sheet;
modify the JSX for SheetContent (used inside the isMobile branch) to accept ref,
{...props} and the merged className and style so consumer attributes (className,
id, data-*, refs) propagate the same way as in the collapsible="none" and
desktop branches.

In `@tutor-finder/src/components/ui/sonner.tsx`:
- Around line 1-2: The sonner integration should be removed to avoid the broken
next-themes dependency and duplicate toasters: delete or stop using the
sonner.tsx module (remove imports of { Toaster as Sonner, toast } and any
useTheme() calls), and in App.tsx keep only your existing custom Toaster from
hooks/use-toast.ts (remove the Sonner component and any calls to toast from
sonner); if you do want a single sonner-based system instead, replace the
useTheme() usage with a Vite/React-compatible theme provider and remove the
custom Toaster, ensuring only one toaster component (Sonner or your Toaster) is
rendered.

In `@tutor-finder/src/hooks/use-toast.ts`:
- Around line 166-177: The effect in useToast currently depends on state causing
listeners to be removed and re-added on every state change; update the
React.useEffect dependency array for the listener registration/cleanup to an
empty array so the effect runs only once on mount and once on unmount, keeping
the listeners array stable and ensuring setState subscriptions
(listeners.push/setState and the cleanup that splices listeners) are not
resubscribed on each state update.

In `@tutor-finder/src/hooks/useTutorSearch.ts`:
- Around line 44-53: startSearch currently kicks off fetch requests without
cancellation; create an AbortController at the start of startSearch (and keep
the current controller in a ref like searchAbortRef), call
searchAbortRef.current?.abort() before creating a new controller to cancel prior
requests, pass controller.signal into all fetch calls started by startSearch,
catch and ignore AbortError in the promise handlers to avoid setState on
unmounted components, and add a cleanup useEffect that aborts the controller on
unmount; refer to startSearch, setState, and any internal fetch calls invoked by
startSearch to make these changes.
- Around line 32-42: resetSearch currently only clears state (setState) but
doesn't cancel ongoing network/search operations; modify the hook to track the
current AbortController (or cancellation token) in a ref (e.g., currentAbortRef)
used by your search routine (the function that initiates searches) and call
currentAbortRef.current?.abort() inside resetSearch before clearing state, then
clear/reset the ref after aborting so no in-flight promise can later call
setState; update the search-start path to create and assign a new
AbortController to that ref for each new search and wire fetch/requests to use
its signal so they can be cancelled.

In `@tutor-finder/supabase/functions/search-tutors-mino/index.ts`:
- Around line 122-124: The SSE error payload is built by interpolating
error.message directly, which can break JSON if it contains quotes/special
chars; instead serialize the whole object with JSON.stringify before sending so
the message is properly escaped—replace the current
writer.write(encoder.encode(`data: {"type":"ERROR","message":"${error instanceof
Error ? error.message : 'Unknown error'}"}\n\n`)) usage with constructing an
object (e.g., { type: "ERROR", message: error instanceof Error ? error.message :
"Unknown error" }), JSON.stringify(...) and then write `data: ${json}\n\n` via
writer.write and encoder.encode to ensure valid SSE JSON.
- Around line 105-110: The SSE forwarding loop currently splits the incoming
stream by single newlines and writes each non-empty line via
writer.write(encoder.encode(line + '\n')), which strips required SSE event
boundaries; change the parsing to split incoming chunks on '\n\n' to extract
full SSE events (instead of splitting on '\n'), and when forwarding each event
use the proper '\n\n' delimiter (e.g., rewrite each event string and call
writer.write(encoder.encode(event + '\n\n'))). Update the code that produces
"lines" to produce "events" split by '\n\n' and keep references to writer and
encoder (and the loop that iterates over lines/events) so event boundaries
conform to the SSE protocol.
🟡 Minor comments (16)
tutor-finder/src/components/ui/alert.tsx-29-34 (1)

29-34: ⚠️ Potential issue | 🟡 Minor

Fix ref type mismatch: declares HTMLParagraphElement but renders <h5>.

The forwardRef type annotation specifies HTMLParagraphElement, but the component renders an <h5> heading element. This will cause TypeScript errors if consumers pass a ref expecting heading element methods.

🔧 Proposed fix
-const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
+const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
   ({ className, ...props }, ref) => (
     <h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
   ),
 );
tutor-finder/src/components/ui/alert.tsx-36-41 (1)

36-41: ⚠️ Potential issue | 🟡 Minor

Fix ref type mismatch: declares HTMLParagraphElement but renders <div>.

Similar issue here—the ref is typed as HTMLParagraphElement but the component renders a <div>.

🔧 Proposed fix
-const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
+const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
   ({ className, ...props }, ref) => (
     <div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
   ),
 );
tutor-finder/src/components/CompareDashboard.tsx-59-61 (1)

59-61: ⚠️ Potential issue | 🟡 Minor

Add accessible label to close button.

The close button only contains an icon without a text label or aria-label, making it inaccessible to screen reader users.

♿ Proposed fix
-          <Button variant="ghost" size="icon" onClick={onClose}>
+          <Button variant="ghost" size="icon" onClick={onClose} aria-label="Close comparison">
             <X className="w-6 h-6" />
           </Button>
tutor-finder/src/components/ui/card.tsx-17-22 (1)

17-22: ⚠️ Potential issue | 🟡 Minor

Type mismatch in CardTitle ref and element types.

The ref type is HTMLParagraphElement but the component renders an <h3> element (which should use HTMLHeadingElement). This could cause TypeScript issues when consumers try to access heading-specific properties on the ref.

🔧 Proposed fix
-const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
+const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
   ({ className, ...props }, ref) => (
     <h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
   ),
 );
tutor-finder/src/components/ui/breadcrumb.tsx-69-81 (1)

69-81: ⚠️ Potential issue | 🟡 Minor

Fix displayName typo.

BreadcrumbElipssis should be BreadcrumbEllipsis.

🔧 Suggested fix
-BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
+BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";
tutor-finder/src/components/ui/breadcrumb.tsx-7-12 (1)

7-12: ⚠️ Potential issue | 🟡 Minor

Remove or implement the separator prop.

It’s part of the public API but unused, which is misleading for consumers.

🧹 Suggested cleanup
 const Breadcrumb = React.forwardRef<
   HTMLElement,
-  React.ComponentPropsWithoutRef<"nav"> & {
-    separator?: React.ReactNode;
-  }
+  React.ComponentPropsWithoutRef<"nav">
 >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
tutor-finder/src/components/ui/input-otp.tsx-24-49 (1)

24-49: ⚠️ Potential issue | 🟡 Minor

Guard against missing OTP context or invalid slot index.

OTPInputContext can be undefined if InputOTPSlot is used outside of an InputOTP component tree, causing a crash on destructure. While InputOTPSlot is intended for use within InputOTP, adding a defensive guard would prevent unexpected failures and improve robustness.

💡 Suggested safeguard
-  const inputOTPContext = React.useContext(OTPInputContext);
-  const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
+  const inputOTPContext = React.useContext(OTPInputContext);
+  const slot = inputOTPContext?.slots?.[index];
+
+  if (!slot) {
+    return (
+      <div
+        ref={ref}
+        className={cn(
+          "relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
+          className,
+        )}
+        {...props}
+      />
+    );
+  }
+
+  const { char, hasFakeCaret, isActive } = slot;
tutor-finder/src/components/ui/carousel.tsx-91-103 (1)

91-103: ⚠️ Potential issue | 🟡 Minor

Missing cleanup for "reInit" event listener.

The effect subscribes to both reInit and select events but only unsubscribes from select in the cleanup function. This can cause memory leaks or stale callback invocations if the carousel is unmounted or api/onSelect changes.

🔧 Proposed fix
      return () => {
+       api?.off("reInit", onSelect);
        api?.off("select", onSelect);
      };
tutor-finder/src/components/ui/chart.tsx-210-214 (1)

210-214: ⚠️ Potential issue | 🟡 Minor

Zero values are not displayed in tooltip.

The condition item.value && is falsy for 0, causing legitimate zero values to be hidden. This could mislead users when viewing charts with zero data points.

🐛 Proposed fix
-                      {item.value && (
+                      {item.value !== undefined && item.value !== null && (
                         <span className="font-mono font-medium tabular-nums text-foreground">
                           {item.value.toLocaleString()}
                         </span>
                       )}
tutor-finder/src/test/setup.ts-13-13 (1)

13-13: ⚠️ Potential issue | 🟡 Minor

dispatchEvent should return a boolean.

Per the MediaQueryList interface, dispatchEvent returns a boolean (true if the event was not cancelled). The current no-op returns undefined.

Proposed fix
-    dispatchEvent: () => {},
+    dispatchEvent: () => true,
tutor-finder/src/pages/Index.tsx-104-117 (1)

104-117: ⚠️ Potential issue | 🟡 Minor

Clicking Compare with fewer than 2 selections silently does nothing.

When CompareButton is clicked, setShowCompare(true) is called, but CompareDashboard only renders when selectedTutors.length >= 2. If a user clicks Compare with 0 or 1 selection, nothing visible happens—this could be confusing.

Consider either disabling the button when selectedCount < 2 or showing feedback (e.g., a toast) when the user attempts to compare with insufficient selections.

🛠️ Option: Disable button when fewer than 2 selected
       <CompareButton
         selectedCount={state.selectedTutorIds.size}
         onCompare={() => setShowCompare(true)}
+        disabled={state.selectedTutorIds.size < 2}
       />

This requires CompareButton to accept and handle a disabled prop.

tutor-finder/src/components/ui/form.tsx-33-54 (1)

33-54: ⚠️ Potential issue | 🟡 Minor

Move FormField context validation before getFieldState and check for fieldContext?.name.

fieldContext defaults to an empty object {} (line 18), which is truthy, so the guard on line 40 never triggers. This allows getFieldState(fieldContext.name, formState) on line 38 to execute with undefined as the field name when the hook is called outside <FormField>. Move the validation before calling getFieldState and check for the name property instead.

Proposed fix
-  const fieldState = getFieldState(fieldContext.name, formState);
-
-  if (!fieldContext) {
+  if (!fieldContext?.name) {
     throw new Error("useFormField should be used within <FormField>");
   }
+
+  const fieldState = getFieldState(fieldContext.name, formState);
tutor-finder/src/components/ui/menubar.tsx-185-188 (1)

185-188: ⚠️ Potential issue | 🟡 Minor

Fix displayName casing for MenubarShortcut.

displayname won't be picked up by React; use displayName (camelCase).

Proposed fix
-MenubarShortcut.displayname = "MenubarShortcut";
+MenubarShortcut.displayName = "MenubarShortcut";
tutor-finder/src/components/ui/sidebar.tsx-355-369 (1)

355-369: ⚠️ Potential issue | 🟡 Minor

Fix typo in transition property name.

Line 364 uses transition-[margin,opa], which is not a valid CSS property. The correct property is opacity, so the transition won't work. This typo also appears in tenders-finder/, tutors-finder/, and summer-school-finder/ sidebar components.

🛠️ Proposed fix
-          "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
+          "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
tutor-finder/src/integrations/supabase/client.ts-5-6 (1)

5-6: ⚠️ Potential issue | 🟡 Minor

Add runtime validation for required environment variables.

If VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY are undefined, createClient will receive undefined values, leading to cryptic runtime errors. Adding validation provides a clearer error message.

🛡️ Proposed fix
 const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
 const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
+
+if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
+  throw new Error('Missing required Supabase environment variables: VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY');
+}
tutor-finder/supabase/functions/discover-tutor-websites/index.ts-96-114 (1)

96-114: ⚠️ Potential issue | 🟡 Minor

Add validation for parsed website objects.

The parsed JSON is used directly without verifying that each object has the expected name and url properties. Malformed AI responses could result in downstream errors or undefined behavior.

🛡️ Proposed fix to add validation
     try {
       // Try to extract JSON array from the response
       const jsonMatch = content.match(/\[[\s\S]*\]/);
       if (jsonMatch) {
-        websites = JSON.parse(jsonMatch[0]);
+        const parsed = JSON.parse(jsonMatch[0]);
+        // Validate structure
+        if (Array.isArray(parsed) && parsed.every(w => typeof w.name === 'string' && typeof w.url === 'string')) {
+          websites = parsed;
+        } else {
+          throw new Error('Invalid website structure');
+        }
       }
     } catch (parseError) {
🧹 Nitpick comments (25)
tutor-finder/src/components/ui/popover.tsx (1)

29-29: Consider exporting additional Radix primitives if needed.

Radix UI Popover also provides PopoverClose, PopoverArrow, and PopoverAnchor components that are sometimes useful. If any consumers of this module need those, you could add them:

const PopoverClose = PopoverPrimitive.Close;
const PopoverAnchor = PopoverPrimitive.Anchor;

export { Popover, PopoverTrigger, PopoverContent, PopoverClose, PopoverAnchor };

This is optional and depends on your UI requirements.

tutor-finder/vite.config.ts (1)

8-14: Server binds to all network interfaces.

The host: "::" setting exposes the dev server to all network interfaces, not just localhost. This is useful for testing on external devices but makes the server accessible to other machines on your network.

If network exposure is not needed, consider using localhost for stricter binding:

  server: {
-   host: "::",
+   host: "localhost",
    port: 8080,
tutor-finder/src/components/ui/pagination.tsx (1)

34-46: Consider adding forwardRef to PaginationLink for consistency.

PaginationContent and PaginationItem use forwardRef, but PaginationLink does not. This inconsistency could cause issues if consumers need to forward refs to the underlying anchor element.

♻️ Proposed refactor to add forwardRef
-const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
-  <a
-    aria-current={isActive ? "page" : undefined}
-    className={cn(
-      buttonVariants({
-        variant: isActive ? "outline" : "ghost",
-        size,
-      }),
-      className,
-    )}
-    {...props}
-  />
-);
+const PaginationLink = React.forwardRef<HTMLAnchorElement, PaginationLinkProps>(
+  ({ className, isActive, size = "icon", ...props }, ref) => (
+    <a
+      ref={ref}
+      aria-current={isActive ? "page" : undefined}
+      className={cn(
+        buttonVariants({
+          variant: isActive ? "outline" : "ghost",
+          size,
+        }),
+        className,
+      )}
+      {...props}
+    />
+  ),
+);
tutor-finder/eslint.config.js (1)

11-15: Add a Node globals override for tool/config TS files.

The config applies browser globals to all **/*.{ts,tsx}. This project has Node-side TS files (vite.config.ts, vitest.config.ts, tailwind.config.ts, postcss.config.js) that match this glob and will trigger false positives for process, __dirname, and other Node globals. Add a config object below the current one to apply Node globals to these files.

Suggested override
 export default tseslint.config(
   { ignores: ["dist"] },
   {
     extends: [js.configs.recommended, ...tseslint.configs.recommended],
     files: ["**/*.{ts,tsx}"],
     languageOptions: {
       ecmaVersion: 2020,
       globals: globals.browser,
     },
     plugins: {
       "react-hooks": reactHooks,
       "react-refresh": reactRefresh,
     },
     rules: {
       ...reactHooks.configs.recommended.rules,
       "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
       "@typescript-eslint/no-unused-vars": "off",
     },
   },
+  {
+    files: [
+      "**/*.config.{ts,js}",
+      "**/vite.config.ts",
+      "**/vitest.config.ts",
+      "**/tailwind.config.ts",
+      "**/postcss.config.ts",
+    ],
+    languageOptions: {
+      globals: globals.node,
+    },
+  },
 );
tutor-finder/package.json (1)

2-2: Package name doesn't match directory name.

The package is named "vite_react_shadcn_ts" but resides in the tutor-finder directory. Consider updating the name to "tutor-finder" for consistency and clarity when referencing the package in logs, error messages, or monorepo tooling.

📝 Suggested fix
-  "name": "vite_react_shadcn_ts",
+  "name": "tutor-finder",
tutor-finder/src/components/CompareButton.tsx (1)

31-31: Minor: Trailing space in button text when no items selected.

When selectedCount is 0, the button renders "Compare " with a trailing space before the empty string. Consider using a conditional without the space:

✨ Suggested fix
-        Compare {selectedCount > 0 ? `(${selectedCount} selected)` : ''}
+        {selectedCount > 0 ? `Compare (${selectedCount} selected)` : 'Compare'}
tutor-finder/tailwind.config.ts (1)

101-101: Consider using ESM import instead of require() for consistency.

Since this is a TypeScript file using ES module syntax (import/export), using require() is inconsistent. This may also cause issues with stricter TypeScript configurations or bundlers.

✨ Suggested fix
 import type { Config } from "tailwindcss";
+import tailwindcssAnimate from "tailwindcss-animate";

 export default {
   // ... rest of config
-  plugins: [require("tailwindcss-animate")],
+  plugins: [tailwindcssAnimate],
 } satisfies Config;
tutor-finder/src/components/LocationInput.tsx (1)

35-42: Consider adding an accessible label for the location input.

The input relies solely on placeholder text for context. Screen readers benefit from an explicit label. Consider adding aria-label or a visually hidden <label>.

♿ Proposed accessibility improvement
          <Input
            type="text"
            placeholder="Enter pincode or city"
+           aria-label="Location"
            value={location}
            onChange={(e) => setLocation(e.target.value)}
            className="pl-11 h-12 text-base"
            disabled={isLoading}
          />
tutor-finder/src/components/ExamSelector.tsx (1)

38-47: Consider adding aria-pressed for accessibility.

The buttons function as toggle buttons indicating selection state. Adding aria-pressed improves screen reader support.

♿ Proposed accessibility improvement
           <button
             key={type}
             onClick={() => onSelect(type)}
+            aria-pressed={selectedExam === type}
             className={cn(
tutor-finder/src/components/CompareDashboard.tsx (1)

48-62: Consider adding focus management for modal accessibility.

This full-screen modal overlay should trap focus and handle Escape key to close. Without focus management, keyboard users may tab outside the modal or be unable to close it via keyboard.

Consider using Radix Dialog or a focus-trap library to handle:

  • Focus trapping within the modal
  • Escape key to close
  • Return focus to trigger element on close
tutor-finder/src/hooks/useTutorSearch.ts (2)

187-189: Silent catch may hide actionable errors.

Swallowing all parse errors makes debugging difficult. Consider logging non-partial JSON errors or distinguishing between expected incomplete chunks and actual malformed data.

💡 Suggested improvement
               } catch (e) {
-                // Ignore parse errors for incomplete JSON
+                // Log unexpected parse errors (not just incomplete chunks)
+                if (jsonStr.length > 0) {
+                  console.debug(`Agent ${agentId}: Failed to parse SSE data`, jsonStr.slice(0, 100));
+                }
               }

161-177: Consider validating tutor data shape.

The code maps raw response data to Tutor objects using any type casting. Malformed responses could silently produce invalid tutor objects.

Consider adding a validation helper or using a schema validation library (e.g., Zod) to ensure the response matches the expected Tutor structure before adding to state.

tutor-finder/src/hooks/use-mobile.tsx (1)

8-16: Consider using mql.matches directly in the change handler.

The current implementation queries window.innerWidth in the change handler, but mql.matches already provides this information and would be more consistent with the media query definition.

♻️ Suggested simplification
   React.useEffect(() => {
     const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
     const onChange = () => {
-      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+      setIsMobile(mql.matches);
     };
     mql.addEventListener("change", onChange);
-    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+    setIsMobile(mql.matches);
     return () => mql.removeEventListener("change", onChange);
   }, []);
tutor-finder/src/components/ui/carousel.tsx (1)

70-81: Keyboard navigation ignores vertical orientation.

The handler only responds to ArrowLeft/ArrowRight. For vertical carousels, users typically expect ArrowUp/ArrowDown keys.

♻️ Suggested enhancement
     const handleKeyDown = React.useCallback(
       (event: React.KeyboardEvent<HTMLDivElement>) => {
-        if (event.key === "ArrowLeft") {
+        const prevKeys = orientation === "horizontal" ? ["ArrowLeft"] : ["ArrowUp"];
+        const nextKeys = orientation === "horizontal" ? ["ArrowRight"] : ["ArrowDown"];
+        if (prevKeys.includes(event.key)) {
           event.preventDefault();
           scrollPrev();
-        } else if (event.key === "ArrowRight") {
+        } else if (nextKeys.includes(event.key)) {
           event.preventDefault();
           scrollNext();
         }
       },
-      [scrollPrev, scrollNext],
+      [scrollPrev, scrollNext, orientation],
     );
tutor-finder/src/components/ui/sheet.tsx (1)

40-41: Minor: Extra space in class string.

There's a double space between w-3/4 and border-l in the right variant.

✏️ Proposed fix
         right:
-          "inset-y-0 right-0 h-full w-3/4  border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+          "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
tutor-finder/src/main.tsx (1)

1-5: Consider wrapping with React.StrictMode.

The entry point follows standard React 18 patterns. Adding StrictMode helps catch potential issues during development by enabling additional checks and warnings.

♻️ Suggested enhancement
+import { StrictMode } from "react";
 import { createRoot } from "react-dom/client";
 import App from "./App.tsx";
 import "./index.css";

-createRoot(document.getElementById("root")!).render(<App />);
+createRoot(document.getElementById("root")!).render(
+  <StrictMode>
+    <App />
+  </StrictMode>
+);
tutor-finder/tsconfig.json (1)

9-14: Consider enabling stricter TypeScript checks for a new project.

Several type safety options are disabled:

  • strictNullChecks: false — allows null/undefined to flow unchecked, a common source of runtime errors.
  • noImplicitAny: false — permits implicit any types, reducing type coverage.

For a greenfield project, enabling these catches bugs at compile time rather than runtime. If relaxed settings are intentional for rapid prototyping, consider tightening them before production.

tutor-finder/src/pages/NotFound.tsx (1)

16-17: Prefer <Link> over <a> for internal navigation.

Using a plain anchor tag causes a full page reload. Since the app uses React Router, using <Link to="/"> maintains client-side routing and avoids unnecessary reloads.

Proposed fix
+import { useLocation, Link } from "react-router-dom";
-import { useLocation } from "react-router-dom";
-        <a href="/" className="text-primary underline hover:text-primary/90">
+        <Link to="/" className="text-primary underline hover:text-primary/90">
           Return to Home
-        </a>
+        </Link>
tutor-finder/tsconfig.app.json (1)

18-23: Consider enabling stricter TypeScript checks for a new project.

All strictness flags are disabled, which reduces type safety benefits:

  • strict: false disables strict null checks, implicit any, and other safety features
  • noImplicitAny: false allows untyped variables to slip through

For a greenfield project, starting with strict: true is recommended as it's significantly harder to enable later. At minimum, consider enabling noImplicitAny: true to catch common type errors.

♻️ Suggested stricter configuration
     /* Linting */
-    "strict": false,
+    "strict": true,
     "noUnusedLocals": false,
     "noUnusedParameters": false,
-    "noImplicitAny": false,
+    "noImplicitAny": true,
     "noFallthroughCasesInSwitch": false,
tutor-finder/src/pages/Index.tsx (1)

17-17: Consider memoizing selectedTutors for performance.

This filter runs on every render. For small lists this is fine, but if the tutor list grows, consider useMemo:

♻️ Optional optimization
+import { useState, useMemo } from 'react';
-import { useState } from 'react';
 ...
-  const selectedTutors = state.tutors.filter((t) => state.selectedTutorIds.has(t.id));
+  const selectedTutors = useMemo(
+    () => state.tutors.filter((t) => state.selectedTutorIds.has(t.id)),
+    [state.tutors, state.selectedTutorIds]
+  );
tutor-finder/supabase/functions/discover-tutor-websites/index.ts (2)

98-98: Consider a less greedy regex for JSON extraction.

The regex /\[[\s\S]*\]/ uses greedy matching, which could capture content beyond the intended JSON array if the AI response contains multiple brackets. A non-greedy pattern would be safer.

♻️ Proposed fix
-      const jsonMatch = content.match(/\[[\s\S]*\]/);
+      const jsonMatch = content.match(/\[[\s\S]*?\]/);

1-1: Deno std library version is outdated and uses a legacy import pattern.

Version 0.168.0 was released in December 2022 (over 3 years ago). The legacy deno.land/std URL pattern has a final version at 0.224.0, but Deno's standard library has since moved to JSR with separate @std/* packages. Consider upgrading to @std/http via JSR for a more modern, maintainable approach.

tutor-finder/supabase/functions/search-tutors-mino/index.ts (1)

89-89: Same JSON escaping issue here.

This error message is hardcoded so it's safe now, but for consistency and defensive coding, use JSON.stringify.

♻️ Proposed fix
-          await writer.write(encoder.encode('data: {"type":"ERROR","message":"No response body"}\n\n'));
+          await writer.write(encoder.encode(`data: ${JSON.stringify({ type: "ERROR", message: "No response body" })}\n\n`));
tutor-finder/.env (2)

1-3: Avoid committing .env files with actual credentials.

Even though VITE_SUPABASE_PUBLISHABLE_KEY is the public anon key (designed for client-side use), committing .env files directly can lead to accidental exposure of sensitive values in the future. Consider:

  1. Add .env to .gitignore
  2. Create a .env.example with placeholder values for documentation
📝 Example .env.example
VITE_SUPABASE_PROJECT_ID="your-project-id"
VITE_SUPABASE_PUBLISHABLE_KEY="your-anon-key"
VITE_SUPABASE_URL="https://your-project-id.supabase.co"

1-3: Remove unnecessary quotes from environment variable values.

The static analysis tool flagged that quotes in .env values can cause issues with some parsers, as the quotes may be included as part of the value. Most .env parsers don't require quotes for simple values.

♻️ Proposed fix
-VITE_SUPABASE_PROJECT_ID="hspjxcouamcdqrvqhkgg"
-VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhzcGp4Y291YW1jZHFydnFoa2dnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk2NzI4NzEsImV4cCI6MjA4NTI0ODg3MX0.1A4KloHAiPP6ObODTZnXicntDqMfwDbQP-_FoW4eyUY"
-VITE_SUPABASE_URL="https://hspjxcouamcdqrvqhkgg.supabase.co"
+VITE_SUPABASE_PROJECT_ID=hspjxcouamcdqrvqhkgg
+VITE_SUPABASE_PUBLISHABLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhzcGp4Y291YW1jZHFydnFoa2dnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk2NzI4NzEsImV4cCI6MjA4NTI0ODg3MX0.1A4KloHAiPP6ObODTZnXicntDqMfwDbQP-_FoW4eyUY
+VITE_SUPABASE_URL=https://hspjxcouamcdqrvqhkgg.supabase.co

Comment on lines +25 to +33
<div
onClick={onToggleSelect}
className={cn(
'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card',
'hover:shadow-lg hover:border-primary/30',
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border'
)}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "TutorCard.tsx" -type f

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 161


🏁 Script executed:

cat -n ./tutor-finder/src/components/TutorCard.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 5888


Add keyboard and ARIA support to the clickable card.

The card is a <div> with onClick but lacks keyboard handlers and ARIA semantics, making it inaccessible to keyboard and screen reader users. Add role="button", tabIndex={0}, an onKeyDown handler for Enter/Space keys, and aria-pressed={isSelected} to make the selection toggle keyboard-accessible and semantically correct.

♿ Suggested fix
     <div
       onClick={onToggleSelect}
+      onKeyDown={(e) => {
+        if (e.key === "Enter" || e.key === " ") {
+          e.preventDefault();
+          onToggleSelect();
+        }
+      }}
+      role="button"
+      tabIndex={0}
+      aria-pressed={isSelected}
       className={cn(
         'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card',
         'hover:shadow-lg hover:border-primary/30',
         isSelected
           ? 'border-primary bg-primary/5 shadow-md'
           : 'border-border'
       )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
onClick={onToggleSelect}
className={cn(
'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card',
'hover:shadow-lg hover:border-primary/30',
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border'
)}
<div
onClick={onToggleSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggleSelect();
}
}}
role="button"
tabIndex={0}
aria-pressed={isSelected}
className={cn(
'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card',
'hover:shadow-lg hover:border-primary/30',
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border'
)}
</div>
🤖 Prompt for AI Agents
In `@tutor-finder/src/components/TutorCard.tsx` around lines 25 - 33, The div used
as the clickable card in the TutorCard component should be made keyboard- and
screen-reader-accessible: add role="button", tabIndex={0},
aria-pressed={isSelected}, and an onKeyDown handler that listens for Enter and
Space and calls onToggleSelect (for Space call preventDefault to stop page
scroll) so keyboard users can toggle selection; update the element where
onToggleSelect, isSelected and cn are used to include these attributes and the
handler.

Comment on lines +138 to +170
>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();

if (collapsible === "none") {
return (
<div
className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)}
ref={ref}
{...props}
>
{children}
</div>
);
}

if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files | grep -i sidebar

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 390


🏁 Script executed:

head -200 tutor-finder/src/components/ui/sidebar.tsx | tail -100

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 3421


🏁 Script executed:

wc -l tutor-finder/src/components/ui/sidebar.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 119


🏁 Script executed:

sed -n '100,200p' tutor-finder/src/components/ui/sidebar.tsx

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 3437


🏁 Script executed:

# Run the search for Sidebar usages with className/id/data-/aria-/ref as suggested in the review
rg -n --type=tsx -C2 -P '<Sidebar\b[^>]*(className|id|data-|aria-|ref)=' tutor-finder/

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 100


🏁 Script executed:

rg -n -C2 '<Sidebar\b[^>]*(className|id|data-|aria-|ref)=' tutor-finder/

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

# Let's get the complete sidebar.tsx to understand the full picture
sed -n '130,180p' tutor-finder/src/components/ui/sidebar.tsx | cat -n

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1912


🏁 Script executed:

# Let's look for all Sidebar component usages in tutor-finder
rg -n 'Sidebar' tutor-finder/src/ -t jsx -t js | head -30

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 100


🏁 Script executed:

# Check the file type and search differently
find tutor-finder/src -name "*sidebar*" -o -name "*Sidebar*"

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 115


🏁 Script executed:

# Let's see if there are any component files that use Sidebar
rg '<Sidebar' tutor-finder/src/ -A3 -B1 | head -50

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1102


🏁 Script executed:

# Let's look for the Sheet component to understand how it handles props
rg -n 'Sheet' tutor-finder/src/components/ui/ | head -20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 2126


🏁 Script executed:

# Get the complete Sidebar component to see all branches
sed -n '130,220p' tutor-finder/src/components/ui/sidebar.tsx | cat -n

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 4105


Preserve Sidebar props/ref in mobile rendering.

The mobile branch currently spreads ...props onto <Sheet> and doesn't forward ref to SheetContent, so consumer className, id, data-*, and refs don't reach the rendered sidebar on mobile. This is inconsistent with the desktop and collapsible="none" branches, which properly apply these attributes. Move props and ref to SheetContent and merge style/className to maintain consistent behavior across breakpoints.

🛠️ Proposed fix
->(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
+>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, style, children, ...props }, ref) => {
   const { isMobile, state, openMobile, setOpenMobile } = useSidebar();

   if (isMobile) {
     return (
-      <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
+      <Sheet open={openMobile} onOpenChange={setOpenMobile}>
         <SheetContent
+          ref={ref}
           data-sidebar="sidebar"
           data-mobile="true"
-          className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
+          className={cn("w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden", className)}
           style={
             {
               "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
+              ...style,
             } as React.CSSProperties
           }
           side={side}
+          {...props}
         >
           <div className="flex h-full w-full flex-col">{children}</div>
         </SheetContent>
       </Sheet>
     );
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, style, children, ...props }, ref) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
<SheetContent
ref={ref}
data-sidebar="sidebar"
data-mobile="true"
className={cn("w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden", className)}
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
...style,
} as React.CSSProperties
}
side={side}
{...props}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
🤖 Prompt for AI Agents
In `@tutor-finder/src/components/ui/sidebar.tsx` around lines 138 - 170, The
mobile rendering currently spreads ...props onto Sheet and doesn't forward ref
or merge className/style into SheetContent; update the mobile branch so
SheetContent (not Sheet) receives the forwarded ref and the component props
(merge incoming className with the existing class string and merge the style
object to include "--sidebar-width": SIDEBAR_WIDTH_MOBILE while preserving any
provided styles), and remove spreading ...props onto Sheet; modify the JSX for
SheetContent (used inside the isMobile branch) to accept ref, {...props} and the
merged className and style so consumer attributes (className, id, data-*, refs)
propagate the same way as in the collapsible="none" and desktop branches.

Comment on lines +1 to +2
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if ThemeProvider is set up and which toast system is actually used
echo "=== Checking for ThemeProvider setup ==="
rg -n "ThemeProvider" --type=tsx --type=ts tutor-finder/src/

echo ""
echo "=== Checking toast usage patterns ==="
rg -n "from ['\"]sonner['\"]|from.*use-toast" --type=tsx --type=ts tutor-finder/src/

echo ""
echo "=== Checking app root/layout structure ==="
fd -e tsx -e ts "app\|main\|root\|layout" tutor-finder/src/ | head -20

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 269


🏁 Script executed:

#!/bin/bash
# Explore directory structure first
echo "=== Repository structure ==="
git ls-files | head -30

echo ""
echo "=== Checking for ThemeProvider setup ==="
rg -n "ThemeProvider" 

echo ""
echo "=== Checking for sonner imports ==="
rg -n "from.*['\"]sonner['\"]"

echo ""
echo "=== Checking for useTheme imports ==="
rg -n "from.*['\"]next-themes['\"]"

echo ""
echo "=== Checking for useToast imports ==="
rg -n "useToast"

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 5152


🏁 Script executed:

#!/bin/bash
echo "=== tutor-finder app/main structure ==="
find tutor-finder/src -maxdepth 2 -type f -name "*.tsx" -o -name "*.ts" | grep -E "(main|app|root|layout|index)" | head -20

echo ""
echo "=== tutor-finder package.json ==="
cat tutor-finder/package.json

echo ""
echo "=== Check if ThemeProvider is used in tutor-finder ==="
rg -n "ThemeProvider" tutor-finder/

echo ""
echo "=== Check actual usage of sonner in tutor-finder ==="
rg -n "Sonner|toast" tutor-finder/src/components/ui/sonner.tsx -A 20

echo ""
echo "=== Check if sonner Toaster is rendered anywhere ==="
rg -n "<Sonner|<Toaster" tutor-finder/src/

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 4488


Remove unused sonner integration or consolidate toast systems.

The sonner.tsx file has two unresolved issues:

  1. Missing ThemeProvider: The useTheme() hook from next-themes requires a ThemeProvider wrapper at the app root, which is not present in this Vite+React setup. Without it, the hook will fail. next-themes is designed for Next.js and requires additional configuration for Vite+React.

  2. Duplicate toast systems: Both <Toaster /> (custom, from hooks/use-toast.ts) and <Sonner /> (from sonner.tsx) are rendered simultaneously in App.tsx (lines 14–15). Choose one and remove the other to avoid maintenance overhead and potential conflicts.

🤖 Prompt for AI Agents
In `@tutor-finder/src/components/ui/sonner.tsx` around lines 1 - 2, The sonner
integration should be removed to avoid the broken next-themes dependency and
duplicate toasters: delete or stop using the sonner.tsx module (remove imports
of { Toaster as Sonner, toast } and any useTheme() calls), and in App.tsx keep
only your existing custom Toaster from hooks/use-toast.ts (remove the Sonner
component and any calls to toast from sonner); if you do want a single
sonner-based system instead, replace the useTheme() usage with a
Vite/React-compatible theme provider and remove the custom Toaster, ensuring
only one toaster component (Sonner or your Toaster) is rendered.

Comment on lines +166 to +177
function useToast() {
const [state, setState] = React.useState<State>(memoryState);

React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and read the file
find . -path "*/tutor-finder/src/hooks/use-toast.ts" -type f

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 110


🏁 Script executed:

# Read the file to see the full context
cat -n tutor-finder/src/hooks/use-toast.ts

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 5309


Fix the useEffect dependency array to prevent listener resubscription on every state change.

The effect sets up a listener subscription and should only run once on mount and cleanup once on unmount. Including state in the dependency array causes unnecessary teardown and re-subscription on every state change, which can miss dispatches between cleanup and re-setup.

Proposed fix
-  React.useEffect(() => {
-    listeners.push(setState);
-    return () => {
-      const index = listeners.indexOf(setState);
-      if (index > -1) {
-        listeners.splice(index, 1);
-      }
-    };
-  }, [state]);
+  React.useEffect(() => {
+    listeners.push(setState);
+    return () => {
+      const index = listeners.indexOf(setState);
+      if (index > -1) {
+        listeners.splice(index, 1);
+      }
+    };
+  }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, []);
🤖 Prompt for AI Agents
In `@tutor-finder/src/hooks/use-toast.ts` around lines 166 - 177, The effect in
useToast currently depends on state causing listeners to be removed and re-added
on every state change; update the React.useEffect dependency array for the
listener registration/cleanup to an empty array so the effect runs only once on
mount and once on unmount, keeping the listeners array stable and ensuring
setState subscriptions (listeners.push/setState and the cleanup that splices
listeners) are not resubscribed on each state update.

Comment on lines +32 to +42
const resetSearch = () => {
setState({
exam: null,
location: '',
isSearching: false,
isDiscovering: false,
agents: [],
tutors: [],
selectedTutorIds: new Set(),
});
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

resetSearch should abort in-flight requests.

Currently resetSearch only clears state but doesn't cancel active searches. If a search is in progress, it will continue running and eventually update state, potentially overwriting the reset state.

🔧 Proposed fix
   const resetSearch = () => {
+    abortControllerRef.current?.abort();
     setState({
       exam: null,
       location: '',
       isSearching: false,
       isDiscovering: false,
       agents: [],
       tutors: [],
       selectedTutorIds: new Set(),
     });
   };
🤖 Prompt for AI Agents
In `@tutor-finder/src/hooks/useTutorSearch.ts` around lines 32 - 42, resetSearch
currently only clears state (setState) but doesn't cancel ongoing network/search
operations; modify the hook to track the current AbortController (or
cancellation token) in a ref (e.g., currentAbortRef) used by your search routine
(the function that initiates searches) and call currentAbortRef.current?.abort()
inside resetSearch before clearing state, then clear/reset the ref after
aborting so no in-flight promise can later call setState; update the
search-start path to create and assign a new AbortController to that ref for
each new search and wire fetch/requests to use its signal so they can be
cancelled.

Comment on lines +44 to +53
const startSearch = useCallback(async (exam: ExamType, location: string) => {
setState((prev) => ({
...prev,
location,
isSearching: true,
isDiscovering: true,
agents: [],
tutors: [],
selectedTutorIds: new Set(),
}));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing AbortController for request cancellation.

The startSearch function initiates multiple fetch requests but provides no mechanism to abort them if the component unmounts or the user triggers a new search. This can cause memory leaks and "setState on unmounted component" warnings.

🔧 Proposed fix - add AbortController
+import { useState, useCallback, useRef, useEffect } from 'react';
+
 export function useTutorSearch() {
+  const abortControllerRef = useRef<AbortController | null>(null);
+
   const [state, setState] = useState<SearchState>({
     // ...
   });
+
+  // Cleanup on unmount
+  useEffect(() => {
+    return () => {
+      abortControllerRef.current?.abort();
+    };
+  }, []);

   const startSearch = useCallback(async (exam: ExamType, location: string) => {
+    // Abort any in-flight requests
+    abortControllerRef.current?.abort();
+    abortControllerRef.current = new AbortController();
+    const signal = abortControllerRef.current.signal;
+
     setState((prev) => ({
       // ...
     }));

Then pass signal to fetch calls:

           const response = await fetch(
             `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/search-tutors-mino`,
             {
               method: 'POST',
+              signal,
               headers: {
🤖 Prompt for AI Agents
In `@tutor-finder/src/hooks/useTutorSearch.ts` around lines 44 - 53, startSearch
currently kicks off fetch requests without cancellation; create an
AbortController at the start of startSearch (and keep the current controller in
a ref like searchAbortRef), call searchAbortRef.current?.abort() before creating
a new controller to cancel prior requests, pass controller.signal into all fetch
calls started by startSearch, catch and ignore AbortError in the promise
handlers to avoid setState on unmounted components, and add a cleanup useEffect
that aborts the controller on unmount; refer to startSearch, setState, and any
internal fetch calls invoked by startSearch to make these changes.

Comment on lines +105 to +110
for (const line of lines) {
if (line.trim()) {
// Forward the SSE event to the client
await writer.write(encoder.encode(line + '\n'));
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's read the file to understand the context around lines 105-110
cat -n tutor-finder/supabase/functions/search-tutors-mino/index.ts | head -120 | tail -40

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1562


🏁 Script executed:

# Also check file size to understand the full scope
wc -l tutor-finder/supabase/functions/search-tutors-mino/index.ts

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 136


🏁 Script executed:

# Run the verification script to find SSE client implementations
rg -n "EventSource|text/event-stream" --type ts --type tsx -g '!node_modules'

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 100


🏁 Script executed:

# Search for SSE and EventSource with correct flags
rg -n "EventSource|text/event-stream" --type ts -g '!node_modules'

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1443


🏁 Script executed:

# Search for Mino API references and documentation
rg -n "mino|Mino" --type ts -A 3 -B 3 | head -100

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 8571


🏁 Script executed:

# Look at the full file to understand the Mino API response handling
cat -n tutor-finder/supabase/functions/search-tutors-mino/index.ts

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 6070


🏁 Script executed:

# Search for EventSource usage in client code
rg -n "EventSource|readyState|addEventListener.*message" tutor-finder/src -A 3

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 55


🏁 Script executed:

# Also check for any other SSE stream handling patterns
rg -n "\.split\('\\\\n'\)|event-stream" tutor-finder -B 2 -A 2

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 1398


🏁 Script executed:

# Check the useTutorSearch.ts file to see how it consumes the SSE stream
cat -n tutor-finder/src/hooks/useTutorSearch.ts | sed -n '100,160p'

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 2548


🏁 Script executed:

# Also look for fetch response handling with SSE
cat -n tutor-finder/src/hooks/useTutorSearch.ts | sed -n '100,180p'

Repository: tinyfish-io/tinyfish-cookbook

Length of output: 3664


SSE events must use double newlines (\n\n) as delimiters per protocol specification.

The current logic splits incoming data by \n (line 102) and filters empty lines (line 106), which strips the double-newline event boundaries from the Mino API stream. While the client-side parser works around this by checking for data: prefix, this violates SSE protocol and will break with standard SSE clients (e.g., EventSource API).

To fix: preserve event boundaries when forwarding:

  • Split by \n\n instead of \n to isolate complete events
  • Rewrite each event with proper \n\n delimiters
🤖 Prompt for AI Agents
In `@tutor-finder/supabase/functions/search-tutors-mino/index.ts` around lines 105
- 110, The SSE forwarding loop currently splits the incoming stream by single
newlines and writes each non-empty line via writer.write(encoder.encode(line +
'\n')), which strips required SSE event boundaries; change the parsing to split
incoming chunks on '\n\n' to extract full SSE events (instead of splitting on
'\n'), and when forwarding each event use the proper '\n\n' delimiter (e.g.,
rewrite each event string and call writer.write(encoder.encode(event +
'\n\n'))). Update the code that produces "lines" to produce "events" split by
'\n\n' and keep references to writer and encoder (and the loop that iterates
over lines/events) so event boundaries conform to the SSE protocol.

Comment on lines +122 to +124
try {
await writer.write(encoder.encode(`data: {"type":"ERROR","message":"${error instanceof Error ? error.message : 'Unknown error'}"}\n\n`));
await writer.close();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Escape error message to prevent malformed JSON in SSE events.

If error.message contains quotes or special characters, the JSON in the SSE event will be malformed, potentially breaking client-side parsing.

🐛 Proposed fix
         try {
-          await writer.write(encoder.encode(`data: {"type":"ERROR","message":"${error instanceof Error ? error.message : 'Unknown error'}"}\n\n`));
+          const errorMsg = error instanceof Error ? error.message : 'Unknown error';
+          await writer.write(encoder.encode(`data: ${JSON.stringify({ type: "ERROR", message: errorMsg })}\n\n`));
           await writer.close();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
await writer.write(encoder.encode(`data: {"type":"ERROR","message":"${error instanceof Error ? error.message : 'Unknown error'}"}\n\n`));
await writer.close();
try {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await writer.write(encoder.encode(`data: ${JSON.stringify({ type: "ERROR", message: errorMsg })}\n\n`));
await writer.close();
🤖 Prompt for AI Agents
In `@tutor-finder/supabase/functions/search-tutors-mino/index.ts` around lines 122
- 124, The SSE error payload is built by interpolating error.message directly,
which can break JSON if it contains quotes/special chars; instead serialize the
whole object with JSON.stringify before sending so the message is properly
escaped—replace the current writer.write(encoder.encode(`data:
{"type":"ERROR","message":"${error instanceof Error ? error.message : 'Unknown
error'}"}\n\n`)) usage with constructing an object (e.g., { type: "ERROR",
message: error instanceof Error ? error.message : "Unknown error" }),
JSON.stringify(...) and then write `data: ${json}\n\n` via writer.write and
encoder.encode to ensure valid SSE JSON.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant